diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cf9c3f7a..575a9cc0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -167,6 +167,36 @@ jobs:
working-directory: server/api
run: bunx tsc --noEmit
+ drizzle-migration-check:
+ name: Drizzle Migration Check
+ # Drizzle TS スキーマを変更したら必ずマイグレーション SQL を追加するルールの強制。
+ # PR #728 のように TS スキーマだけ更新して `server/api/drizzle/*.sql` を忘れると、
+ # 本番 DB がスキーマに追いつかず 500 エラーになるため、PR 段階で検出する。
+ # PR 限定(push にはマージベースの計算対象が無い)。
+ #
+ # Enforce: any change under `server/api/src/schema/**` must come with a new
+ # migration SQL file and an updated `_journal.json`. Detects PR #728-style
+ # regressions where production drifts from the application schema.
+ if: github.event_name == 'pull_request' && !github.event.pull_request.draft
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6.0.2
+ with:
+ # PR ベースとの diff を取るため履歴を全部取得する。
+ # Need full history so the script can diff against the PR base.
+ fetch-depth: 0
+
+ - uses: actions/setup-node@v6
+ with:
+ node-version-file: ".nvmrc"
+
+ - name: Run drizzle migration consistency check
+ env:
+ DRIZZLE_DIFF_BASE: origin/${{ github.base_ref }}
+ PR_TITLE: ${{ github.event.pull_request.title }}
+ PR_BODY: ${{ github.event.pull_request.body }}
+ run: node scripts/check-drizzle-migrations.mjs
+
mcp-test:
name: MCP Server Tests
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
diff --git a/AGENTS.md b/AGENTS.md
index 995849cb..7cb25c3b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -55,6 +55,22 @@ bun run test:run # Vitest 単体テスト
- 既存のディレクトリ構成・命名規則に合わせる。
- Conventional Commits 形式でコミット(`feat:`, `fix:`, `docs:` 等)。
+## DB スキーマ変更(必読) / Database schema changes (must read)
+
+- **TS スキーマと SQL マイグレーションは常に対で更新する**。`server/api/src/schema/**/*.ts` を編集したら、必ず `server/api/drizzle/NNNN_*.sql` を新規追加し、`server/api/drizzle/meta/_journal.json` にエントリを追記する。
+ _Always pair TS schema edits with a SQL migration: add a new `server/api/drizzle/NNNN_\*.sql`and append an entry to`server/api/drizzle/meta/_journal.json`. Skipping this caused PR #728 → production 500s on `/api/onboarding/status`and`/api/pages`._
+- **正本のマイグレーション置き場は `server/api/drizzle/` のみ**。CI (`deploy-{dev,prod}.yml`) は `bunx drizzle-kit migrate` だけを実行するため、ここ以外に SQL を置いても本番には適用されない。
+ _Source of truth is `server/api/drizzle/`. CI runs only `bunx drizzle-kit migrate`; SQL placed elsewhere is dead code._
+- **マイグレーションの書き方**:
+ - 既存の手書き例(`0017_add_link_type.sql` など)の体裁に合わせ、ステートメント間に `--> statement-breakpoint` を入れる。
+ - 既存環境で重複適用されても安全になるよう、原則として `IF NOT EXISTS` / `ON CONFLICT DO NOTHING` を使う。
+ - 必要であればバックフィル(既存行への初期値投入)も同じファイル内で行う。
+ - `bunx drizzle-kit generate` で雛形を作るときは、過去スナップショットが欠落しているため巨大な diff が出ることがある。その場合は `--name` 指定の出力を手で削減し、既存マイグレーション間で重複しない形に整えてから commit する(snapshot ファイルは生成物のみ、当面コミットしない方針)。
+- **CI ガード**: `.github/workflows/ci.yml` の `drizzle-migration-check` ジョブが PR で `server/api/src/schema/**` の変更と新規 `server/api/drizzle/*.sql` がペアになっているかを検証する。例外的に SQL 不要な場合(コメント/JSDoc 修正のみなど)は PR 本文かコミットメッセージに `[skip drizzle-check]` を入れる。
+ _CI guard `drizzle-migration-check` enforces the schema/migration pairing. Use the `[skip drizzle-check]` marker only for non-DDL edits (comments, JSDoc, type aliases that do not affect SQL)._
+- **環境別の自動適用**: `develop` への push → `deploy-dev.yml` が development DB へ migrate。`main` への push → `deploy-prod.yml` が production DB へ migrate。スキーマ追従はこの 2 本だけ。
+ _Auto-apply: push to `develop` migrates dev DB; push to `main` migrates prod DB. No other path applies migrations._
+
## ブランチ・PR の命名規則
- **ブランチ**: `feature/説明`、`fix/説明`、`hotfix/説明`、`chore/説明` など(例: `feature/ai-models-ui`, `fix/search-crash`)。Issue 番号から作る場合は `feature/123`。
diff --git a/CLAUDE.md b/CLAUDE.md
index eb2f4088..5ffa64e8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -27,6 +27,11 @@
- エラーハンドリングとログが適切か。
- 日本語・英語のコメント・ドキュメントがプロジェクトのトーンに合っているか。
+## DB スキーマ変更
+
+- TS スキーマ (`server/api/src/schema/**`) を変更した PR では必ず `server/api/drizzle/NNNN_*.sql` を新規追加し、`server/api/drizzle/meta/_journal.json` にもエントリを追記する。詳細は [AGENTS.md §「DB スキーマ変更」](./AGENTS.md#db-スキーマ変更必読--database-schema-changes-must-read) を参照。
+- CI の `drizzle-migration-check` ジョブが PR でスキーマ変更と SQL 追加のペアを強制する。
+
## その他
- 変更が大きい場合は小さな PR に分けることを推奨する。
diff --git a/README.md b/README.md
index 898d6da7..53ceea84 100644
--- a/README.md
+++ b/README.md
@@ -245,7 +245,7 @@ VITE_REALTIME_URL=ws://localhost:1234 # 本番は wss://realtime.zedi-note.app
| **Visualization** | Recharts / `@xyflow/react` (React Flow) / Mermaid / KaTeX / Tesseract.js (OCR) |
| **Auth** | [Better Auth](https://better-auth.com/) (OAuth / セッション cookie) |
| **API** | `server/api` — Hono on Bun + Drizzle ORM (PostgreSQL) |
-| **Database** | PostgreSQL (Drizzle migrations: `db/migrations`, `server/api/drizzle`) / IndexedDB (local・ブラウザ) |
+| **Database** | PostgreSQL (Drizzle migrations: `server/api/drizzle/`) / IndexedDB (local・ブラウザ) |
| **Realtime** | `server/hocuspocus` — Hocuspocus (Y.js) によるリアルタイム共同編集 |
| **MCP** | `server/mcp` — Claude Code 連携(stdio / HTTP、詳細は [server/mcp/README.md](server/mcp/README.md)) |
| **Storage** | AWS S3(API 経由でアップロード、`@aws-sdk/client-s3`) |
@@ -337,7 +337,7 @@ packages/ # Bun workspaces(共有ライブラリ)
admin/ # 管理画面アプリ(別 Vite + React + Tailwind / `@zedi/ui` 利用)
extension/ # ブラウザ拡張(Manifest v3、Web Clipper)
-db/migrations/ # PostgreSQL マイグレーション SQL
+server/api/drizzle/ # PostgreSQL マイグレーション(drizzle-kit が読む正本 / source of truth)
terraform/cloudflare/ # Cloudflare 関連インフラ定義
e2e/ # Playwright E2E テスト
scripts/ # セットアップ / sidecar ビルド / Stryker / 拡張ビルド等のスクリプト
diff --git a/db/migrations/001_add_notes_tables.sql b/db/migrations/001_add_notes_tables.sql
deleted file mode 100644
index 3a036bf4..00000000
--- a/db/migrations/001_add_notes_tables.sql
+++ /dev/null
@@ -1,49 +0,0 @@
--- Migration: 001_add_notes_tables
--- Description: Add notes, note_pages, and note_members tables for sharing feature
--- Date: 2026-01-23
-
--- 公開ノート
-CREATE TABLE IF NOT EXISTS notes (
- id TEXT PRIMARY KEY,
- owner_user_id TEXT NOT NULL,
- title TEXT,
- visibility TEXT NOT NULL DEFAULT 'private',
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL,
- is_deleted INTEGER DEFAULT 0
-);
-
-CREATE INDEX IF NOT EXISTS idx_notes_owner ON notes(owner_user_id);
-CREATE INDEX IF NOT EXISTS idx_notes_visibility ON notes(visibility);
-
--- ノート内ページ
-CREATE TABLE IF NOT EXISTS note_pages (
- note_id TEXT NOT NULL,
- page_id TEXT NOT NULL,
- added_by_user_id TEXT NOT NULL,
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL,
- is_deleted INTEGER DEFAULT 0,
- FOREIGN KEY(note_id) REFERENCES notes(id) ON DELETE CASCADE,
- FOREIGN KEY(page_id) REFERENCES pages(id) ON DELETE CASCADE,
- PRIMARY KEY (note_id, page_id)
-);
-
-CREATE INDEX IF NOT EXISTS idx_note_pages_note ON note_pages(note_id);
-CREATE INDEX IF NOT EXISTS idx_note_pages_page ON note_pages(page_id);
-
--- ノートメンバー
-CREATE TABLE IF NOT EXISTS note_members (
- note_id TEXT NOT NULL,
- member_email TEXT NOT NULL,
- role TEXT NOT NULL DEFAULT 'viewer',
- invited_by_user_id TEXT NOT NULL,
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL,
- is_deleted INTEGER DEFAULT 0,
- FOREIGN KEY(note_id) REFERENCES notes(id) ON DELETE CASCADE,
- PRIMARY KEY (note_id, member_email)
-);
-
-CREATE INDEX IF NOT EXISTS idx_note_members_note ON note_members(note_id);
-CREATE INDEX IF NOT EXISTS idx_note_members_email ON note_members(member_email);
diff --git a/db/migrations/002_add_page_snapshots.sql b/db/migrations/002_add_page_snapshots.sql
deleted file mode 100644
index 70557beb..00000000
--- a/db/migrations/002_add_page_snapshots.sql
+++ /dev/null
@@ -1,19 +0,0 @@
--- Migration: 002_add_page_snapshots
--- Description: Add page_snapshots table for page version history
--- Date: 2026-04-07
-
--- ページスナップショット(バージョン履歴)
--- Page snapshots (version history)
-CREATE TABLE IF NOT EXISTS page_snapshots (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- page_id UUID NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
- version BIGINT NOT NULL,
- ydoc_state BYTEA NOT NULL,
- content_text TEXT,
- created_by TEXT,
- trigger TEXT NOT NULL DEFAULT 'auto',
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-);
-
-CREATE INDEX IF NOT EXISTS idx_page_snapshots_page_id ON page_snapshots(page_id);
-CREATE INDEX IF NOT EXISTS idx_page_snapshots_page_created ON page_snapshots(page_id, created_at DESC);
diff --git a/db/migrations/003_add_invitation_tokens.sql b/db/migrations/003_add_invitation_tokens.sql
deleted file mode 100644
index bc5461c7..00000000
--- a/db/migrations/003_add_invitation_tokens.sql
+++ /dev/null
@@ -1,33 +0,0 @@
--- 003: 招待トークン管理 / Invitation token management
--- note_members に招待ステータスを追加し、note_invitations テーブルを新規作成する。
--- Add invitation status to note_members and create note_invitations table.
-
--- ── note_members: ステータス + 承認ユーザー ID を追加 ─────────────────────────
-ALTER TABLE note_members
- ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'pending'
- CHECK (status IN ('pending', 'accepted', 'declined'));
-
--- Backfill: treat all existing non-deleted members as accepted
--- 既存の有効メンバーを accepted に設定する
-UPDATE note_members SET status = 'accepted' WHERE is_deleted = FALSE;
-
-ALTER TABLE note_members
- ADD COLUMN IF NOT EXISTS accepted_user_id TEXT REFERENCES "user"(id) ON DELETE SET NULL;
-
--- ── note_invitations テーブル ─────────────────────────────────────────────────
-CREATE TABLE IF NOT EXISTS note_invitations (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- note_id UUID NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
- member_email TEXT NOT NULL,
- token TEXT NOT NULL UNIQUE,
- expires_at TIMESTAMPTZ NOT NULL,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- used_at TIMESTAMPTZ,
- UNIQUE(note_id, member_email)
-);
-
-CREATE INDEX IF NOT EXISTS idx_note_invitations_token
- ON note_invitations(token);
-
-CREATE INDEX IF NOT EXISTS idx_note_invitations_note_id
- ON note_invitations(note_id);
diff --git a/db/migrations/004_add_invitation_locale_tracking.sql b/db/migrations/004_add_invitation_locale_tracking.sql
deleted file mode 100644
index 91e137e7..00000000
--- a/db/migrations/004_add_invitation_locale_tracking.sql
+++ /dev/null
@@ -1,18 +0,0 @@
--- 004: 招待メールのロケール対応と送信トラッキング
--- Invitation email locale support and send tracking
---
--- note_invitations に以下のカラムを追加:
--- - locale: 招待メールの言語('ja' デフォルト)
--- - last_email_sent_at: 直近の送信日時
--- - email_send_count: 送信回数(再送のたびに +1)
--- Add columns for email locale, last-sent timestamp, and send counter.
-
-ALTER TABLE note_invitations
- ADD COLUMN IF NOT EXISTS locale TEXT NOT NULL DEFAULT 'ja'
- CHECK (locale IN ('ja', 'en'));
-
-ALTER TABLE note_invitations
- ADD COLUMN IF NOT EXISTS last_email_sent_at TIMESTAMPTZ;
-
-ALTER TABLE note_invitations
- ADD COLUMN IF NOT EXISTS email_send_count INTEGER NOT NULL DEFAULT 0;
diff --git a/db/migrations/005_add_onboarding_and_page_kind.sql b/db/migrations/005_add_onboarding_and_page_kind.sql
deleted file mode 100644
index 7d32bd1a..00000000
--- a/db/migrations/005_add_onboarding_and_page_kind.sql
+++ /dev/null
@@ -1,69 +0,0 @@
--- 005: ユーザーオンボーディング状況テーブルとページ種別カラムの追加
--- Add user onboarding status table and page kind column
---
--- 1. pages.kind を追加('user' / 'welcome' / 'update_notice')。既存ページは全て 'user'。
--- ウェルカムページはオーナーごとに最大 1 件となる部分ユニークインデックスを張る。
--- 2. 新テーブル user_onboarding_status を作成し、セットアップ完了時刻・ウェルカム
--- ページ生成状況・ホームスライド表示状況・更新情報自動生成トグルを保持する。
---
--- 1. Add pages.kind column ('user' / 'welcome' / 'update_notice'). Existing rows
--- default to 'user'. A partial unique index guarantees at most one live
--- welcome page per owner.
--- 2. Create user_onboarding_status table tracking setup completion, welcome
--- page creation, home slide display, and the auto-update-notice toggle.
-
--- ---- pages.kind ----------------------------------------------------------
-
-ALTER TABLE pages
- ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'user'
- CHECK (kind IN ('user', 'welcome', 'update_notice'));
-
-CREATE INDEX IF NOT EXISTS idx_pages_owner_kind ON pages (owner_id, kind);
-
--- オーナーごとに有効なウェルカムページは最大 1 件
--- At most one live welcome page per owner.
-CREATE UNIQUE INDEX IF NOT EXISTS idx_pages_unique_welcome_per_owner
- ON pages (owner_id)
- WHERE kind = 'welcome' AND is_deleted = false;
-
--- ---- user_onboarding_status ---------------------------------------------
-
-CREATE TABLE IF NOT EXISTS user_onboarding_status (
- user_id TEXT PRIMARY KEY REFERENCES "user" (id) ON DELETE CASCADE,
- setup_completed_at TIMESTAMPTZ,
- welcome_page_created_at TIMESTAMPTZ,
- welcome_page_id UUID REFERENCES pages (id) ON DELETE SET NULL,
- -- セットアップウィザードで選択したロケール。ログイン時リトライがユーザーの
- -- 意図した言語でウェルカムページを生成するために保持する。
- -- Locale chosen at the setup wizard. Retained so login-time retries create
- -- the welcome page in the user's originally selected language.
- requested_locale TEXT
- CHECK (requested_locale IS NULL OR requested_locale IN ('ja', 'en')),
- home_slides_shown_at TIMESTAMPTZ,
- auto_create_update_notice BOOLEAN NOT NULL DEFAULT TRUE,
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-);
-
--- バックグラウンドリトライ対象(セットアップ済みだがウェルカムページ未生成)の
--- 高速検索のための部分インデックス。
--- Partial index for retry lookups (setup completed but welcome page not yet created).
-CREATE INDEX IF NOT EXISTS idx_user_onboarding_status_needs_welcome
- ON user_onboarding_status (setup_completed_at)
- WHERE setup_completed_at IS NOT NULL AND welcome_page_created_at IS NULL;
-
--- ---- バックフィル / Backfill -----------------------------------------------
--- このマイグレーションが走った時点で既に存在するユーザーは、旧フローで
--- セットアップを完了しているとみなして `setup_completed_at = NOW()` を記録する。
--- そうしないと次回ログイン時に全員がオンボーディングウィザードへ戻されてしまう。
--- `welcome_page_created_at` は NULL のままにしておき、バックグラウンドリトライ
--- でウェルカムページを生成する余地を残す(ユーザーが望めば)。
---
--- Backfill: mark all existing users as "setup completed" so they are not
--- forced back through the wizard on their next visit after this migration
--- lands. `welcome_page_created_at` stays NULL so the background retry is
--- free to generate a welcome page later (users can dismiss it anytime).
-INSERT INTO user_onboarding_status (user_id, setup_completed_at, created_at, updated_at)
-SELECT id, NOW(), NOW(), NOW()
-FROM "user"
-ON CONFLICT (user_id) DO NOTHING;
diff --git a/scripts/check-drizzle-migrations.mjs b/scripts/check-drizzle-migrations.mjs
new file mode 100644
index 00000000..a3090ea8
--- /dev/null
+++ b/scripts/check-drizzle-migrations.mjs
@@ -0,0 +1,209 @@
+#!/usr/bin/env node
+/**
+ * Drizzle スキーマ変更とマイグレーションファイルの整合性チェッカー。
+ * Drizzle schema/migration consistency checker.
+ *
+ * 目的 / Purpose:
+ * PR #728 のように `server/api/src/schema/**` の TS スキーマだけを変更し、
+ * 対応する `server/api/drizzle/*.sql` のマイグレーションを忘れる事故を防ぐ。
+ * そうすると本番 DB がスキーマに追いつかず、500 エラーで露見する。
+ *
+ * Catch the failure mode where a contributor edits the Drizzle TS schema
+ * under `server/api/src/schema/**` without committing a matching migration
+ * in `server/api/drizzle/*.sql`. Without this guard, production runs against
+ * a DB that lags the application schema (#728 hit exactly this).
+ *
+ * 仕組み / How it works:
+ * - 比較ベースを決める(環境変数 `DRIZZLE_DIFF_BASE` または既定で `origin/develop`)。
+ * - `git diff --name-only --diff-filter=ADMR ...HEAD` でスキーマ変更を抽出。
+ * - 変更ファイル: `server/api/src/schema/**`
+ * - ただしテスト (`*.test.ts` / `__tests__/`) と純粋な型ファイル (`types/`) は除外。
+ * - 同じ diff で新規追加された `server/api/drizzle/*.sql` または
+ * `server/api/drizzle/meta/_journal.json` の変更が両方あるか確認する。
+ * - スキーマ変更があるのにマイグレーションが追加されていなければ exit 1。
+ *
+ * Compute the diff between HEAD and the configured base (default
+ * `origin/develop`). If any `server/api/src/schema/**` source file changed
+ * but no new `server/api/drizzle/*.sql` was added (and the journal was not
+ * updated), exit non-zero with an actionable message.
+ *
+ * 使い方 / Usage:
+ * node scripts/check-drizzle-migrations.mjs
+ * DRIZZLE_DIFF_BASE=origin/main node scripts/check-drizzle-migrations.mjs
+ *
+ * False positive を出した場合の救済 / Escape hatch:
+ * コメントだけ・JSDoc だけのスキーマ TS 変更や、CHECK 制約に影響しない型の
+ * 別名導入など「DB に当てる必要がない」差分の場合は、PR メッセージに
+ * `[skip drizzle-check]` を含めるか、コミットメッセージに同じ文字列を
+ * 含めることで許容できる(環境変数
+ * `DRIZZLE_SKIP_MARKER` でも可)。
+ */
+
+import { spawnSync } from "node:child_process";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+
+const root = join(dirname(fileURLToPath(import.meta.url)), "..");
+
+const SCHEMA_PREFIX = "server/api/src/schema/";
+const MIGRATION_PREFIX = "server/api/drizzle/";
+const MIGRATION_JOURNAL = "server/api/drizzle/meta/_journal.json";
+
+/**
+ * 既定の比較ベース。develop ブランチへの PR を主用途とするので origin/develop を既定にする。
+ * ローカル実行時は STR DRIZZLE_DIFF_BASE で上書きできる。
+ */
+const DEFAULT_BASE = process.env.DRIZZLE_DIFF_BASE || "origin/develop";
+
+/** SKIP マーカー(PR / コミットメッセージ)。 */
+const SKIP_MARKER = process.env.DRIZZLE_SKIP_MARKER || "[skip drizzle-check]";
+
+/**
+ * @param {readonly string[]} args
+ * @returns {string}
+ */
+function git(args) {
+ const result = spawnSync("git", args, { encoding: "utf8", cwd: root });
+ if (result.status !== 0) {
+ const stderr = result.stderr?.trim() || "(no stderr)";
+ throw new Error(`git ${args.join(" ")} failed (exit ${result.status}): ${stderr}`);
+ }
+ return result.stdout || "";
+}
+
+/**
+ * リモート参照が存在するか確認する。
+ * Pull request CI では base が解決できない場合に fail fast する。
+ */
+function ensureBaseExists(base) {
+ const result = spawnSync("git", ["rev-parse", "--verify", "--quiet", base], {
+ encoding: "utf8",
+ cwd: root,
+ });
+ return result.status === 0;
+}
+
+/**
+ * @param {string} base
+ * @returns {string[]}
+ */
+function changedPaths(base) {
+ const out = git(["diff", "--name-only", "--diff-filter=ADMR", `${base}...HEAD`]);
+ return out
+ .split("\n")
+ .map((s) => s.trim())
+ .filter(Boolean);
+}
+
+/**
+ * @param {string} base
+ * @returns {string[]}
+ */
+function addedPaths(base) {
+ const out = git(["diff", "--name-only", "--diff-filter=A", `${base}...HEAD`]);
+ return out
+ .split("\n")
+ .map((s) => s.trim())
+ .filter(Boolean);
+}
+
+/**
+ * @param {string} path
+ */
+function isRelevantSchemaChange(path) {
+ if (!path.startsWith(SCHEMA_PREFIX)) return false;
+ if (path.endsWith(".test.ts")) return false;
+ if (path.includes("/__tests__/")) return false;
+ if (path.includes("/types/")) return false;
+ return path.endsWith(".ts");
+}
+
+/**
+ * 新規マイグレーション SQL が追加され、かつ journal も更新されたか。
+ * Whether both a new migration SQL was added AND the journal was modified.
+ *
+ * @param {string[]} added
+ * @param {string[]} changed
+ */
+function hasMigrationUpdate(added, changed) {
+ const newSql = added.some(
+ (p) => p.startsWith(MIGRATION_PREFIX) && p.endsWith(".sql") && !p.includes("/meta/"),
+ );
+ const journalUpdated = changed.includes(MIGRATION_JOURNAL);
+ return newSql && journalUpdated;
+}
+
+/**
+ * @param {string} base
+ * @returns {boolean}
+ */
+function hasSkipMarker(base) {
+ const log = git(["log", `${base}..HEAD`, "--pretty=%B"]);
+ if (log.includes(SKIP_MARKER)) return true;
+ const prTitle = process.env.PR_TITLE || "";
+ const prBody = process.env.PR_BODY || "";
+ return prTitle.includes(SKIP_MARKER) || prBody.includes(SKIP_MARKER);
+}
+
+function main() {
+ const base = DEFAULT_BASE;
+
+ if (!ensureBaseExists(base)) {
+ if (process.env.CI === "true") {
+ console.error(
+ `[check-drizzle-migrations] base ref "${base}" not found. Ensure actions/checkout uses fetch-depth: 0 or fetch the PR base branch before running this check.`,
+ );
+ process.exit(1);
+ }
+
+ console.log(
+ `[check-drizzle-migrations] base ref "${base}" not found; skipping check (most likely running outside PR context).`,
+ );
+ return;
+ }
+
+ const changed = changedPaths(base);
+ const added = addedPaths(base);
+
+ const schemaChanges = changed.filter(isRelevantSchemaChange);
+ if (schemaChanges.length === 0) {
+ console.log("[check-drizzle-migrations] no schema changes detected; OK.");
+ return;
+ }
+
+ if (hasMigrationUpdate(added, changed)) {
+ console.log(
+ "[check-drizzle-migrations] schema changes detected and matching drizzle migration was added; OK.",
+ );
+ return;
+ }
+
+ if (hasSkipMarker(base)) {
+ console.log(
+ `[check-drizzle-migrations] schema changes detected but "${SKIP_MARKER}" present; skipping.`,
+ );
+ return;
+ }
+
+ console.error("[check-drizzle-migrations] FAIL");
+ console.error("");
+ console.error("Drizzle schema files were modified but no migration was added:");
+ for (const f of schemaChanges) console.error(` - ${f}`);
+ console.error("");
+ console.error(`Expected: at least one new "${MIGRATION_PREFIX}NNNN_*.sql" file`);
+ console.error(` AND an updated "${MIGRATION_JOURNAL}" entry.`);
+ console.error("");
+ console.error("How to fix / 修正方法:");
+ console.error(" 1. cd server/api && bunx drizzle-kit generate --name ");
+ console.error(" (DB 接続が必要な場合は DATABASE_URL を一時的にダミー値で渡す)");
+ console.error(" 2. 生成された SQL を確認し、必要なら backfill を追記する。");
+ console.error(" 3. drizzle-kit が自動生成するスナップショットが大きすぎる場合は、");
+ console.error(" 既存の手書きマイグレーションスタイル(0017_add_link_type.sql 等)に合わせて");
+ console.error(" diff を最小化した SQL を手書きし、_journal.json も手で追記する。");
+ console.error("");
+ console.error(`If this change truly does not require a DB migration (e.g. JSDoc-only),`);
+ console.error(`include "${SKIP_MARKER}" in a commit message or the PR body.`);
+ process.exit(1);
+}
+
+main();
diff --git a/server/api/drizzle/0018_add_onboarding_and_page_kind.sql b/server/api/drizzle/0018_add_onboarding_and_page_kind.sql
new file mode 100644
index 00000000..d2093a8b
--- /dev/null
+++ b/server/api/drizzle/0018_add_onboarding_and_page_kind.sql
@@ -0,0 +1,114 @@
+-- 0018: Add `pages.kind` column and `user_onboarding_status` table.
+-- 0018: pages.kind カラムと user_onboarding_status テーブルを追加。
+--
+-- Background / 背景:
+-- PR #728 (feat: add welcome page generation and unified media upload UI)
+-- introduced `pages.kind` (`user` / `welcome` / `update_notice`) and the
+-- `user_onboarding_status` table in the Drizzle TS schema, but the matching
+-- `server/api/drizzle/*.sql` migration was never generated. Production and
+-- development databases therefore miss both objects, which surfaces as
+-- `GET /api/onboarding/status` and `POST /api/pages` returning 500
+-- (`relation "user_onboarding_status" does not exist` /
+-- `column "kind" of relation "pages" does not exist`).
+--
+-- PR #728 で `pages.kind` と `user_onboarding_status` を Drizzle TS スキーマに
+-- 追加したものの、対応する `server/api/drizzle/*.sql` のマイグレーション
+-- ファイルが生成されていなかった。その結果、本番 / 開発 DB の両方で
+-- `GET /api/onboarding/status` と `POST /api/pages` が 500 を返していた。
+--
+-- `db/migrations/005_add_onboarding_and_page_kind.sql` には等価な内容が
+-- 置かれていたが、CI (`deploy-{dev,prod}.yml`) は `bunx drizzle-kit migrate`
+-- しか実行しないため、`server/api/drizzle/` に置き直す必要があった。
+--
+-- IF NOT EXISTS を多用しているのは、すでに手動で `db/migrations/005_*.sql` を
+-- 流したことのある環境(開発者ローカル等)でも安全に再実行できるようにする
+-- ため。drizzle-kit 自身は `__drizzle_migrations` テーブルで適用済みかを
+-- 管理するので、本来は IF NOT EXISTS は不要だが、過去経緯への配慮として
+-- 残している。
+--
+-- Use `IF NOT EXISTS` everywhere so that environments which previously ran the
+-- legacy `db/migrations/005_*.sql` manually do not break. drizzle-kit itself
+-- tracks applied migrations in `__drizzle_migrations`, so the guards are only
+-- defense in depth.
+
+-- ── pages.kind ─────────────────────────────────────────────────────────────
+--
+-- ADD COLUMN IF NOT EXISTS と inline CHECK を 1 文にまとめる。
+-- column が新規追加されるときだけ CHECK 制約も同時に作られる。
+-- legacy 環境(手動で旧 005 SQL を流したケース)ですでに column が
+-- 存在する場合は ADD COLUMN ごとスキップされる。
+--
+-- Combine ADD COLUMN IF NOT EXISTS with an inline CHECK so the constraint is
+-- created only when the column itself is created. Legacy environments that
+-- already ran the old `db/migrations/005_*.sql` keep their existing column
+-- and constraint untouched.
+
+ALTER TABLE "pages"
+ ADD COLUMN IF NOT EXISTS "kind" text NOT NULL DEFAULT 'user'
+ CHECK ("kind" IN ('user', 'welcome', 'update_notice'));
+--> statement-breakpoint
+
+CREATE INDEX IF NOT EXISTS "idx_pages_owner_kind"
+ ON "pages" USING btree ("owner_id", "kind");
+--> statement-breakpoint
+
+-- オーナーごとに有効なウェルカムページは最大 1 件。
+-- welcomePageService.ts の `onConflictDoNothing` が target としてこの述語に
+-- 依拠しているため、ここで部分ユニーク index を必ず張る。
+-- At most one live welcome page per owner. The
+-- `onConflictDoNothing` in welcomePageService.ts targets this exact partial
+-- unique index, so it must exist for the upsert to be a no-op on conflict.
+CREATE UNIQUE INDEX IF NOT EXISTS "idx_pages_unique_welcome_per_owner"
+ ON "pages" ("owner_id")
+ WHERE "kind" = 'welcome' AND "is_deleted" = false;
+--> statement-breakpoint
+
+-- ── user_onboarding_status ────────────────────────────────────────────────
+
+CREATE TABLE IF NOT EXISTS "user_onboarding_status" (
+ "user_id" text PRIMARY KEY NOT NULL
+ REFERENCES "user" ("id") ON DELETE CASCADE,
+ "setup_completed_at" timestamp with time zone,
+ "welcome_page_created_at" timestamp with time zone,
+ "welcome_page_id" uuid
+ REFERENCES "pages" ("id") ON DELETE SET NULL,
+ -- セットアップウィザードで選択したロケール。ログイン時リトライ
+ -- (`retryWelcomePageIfNeeded`) がユーザーの意図した言語でウェルカム
+ -- ページを生成するために保持する。NULL は「未選択」。
+ -- Locale chosen at the setup wizard. Retained so login-time retries
+ -- regenerate the welcome page in the user's originally selected language.
+ "requested_locale" text
+ CHECK ("requested_locale" IS NULL OR "requested_locale" IN ('ja', 'en')),
+ "home_slides_shown_at" timestamp with time zone,
+ "auto_create_update_notice" boolean NOT NULL DEFAULT true,
+ "created_at" timestamp with time zone NOT NULL DEFAULT now(),
+ "updated_at" timestamp with time zone NOT NULL DEFAULT now()
+);
+--> statement-breakpoint
+
+-- バックグラウンドリトライ対象(セットアップ済みだがウェルカムページ未生成)の
+-- 高速検索のための部分インデックス。`retryWelcomePageIfNeeded` が WHERE 句で
+-- そのまま使う形に揃えている。
+-- Partial index for the login-time retry scan: rows where setup completed
+-- but the welcome page has not been generated yet.
+CREATE INDEX IF NOT EXISTS "idx_user_onboarding_status_needs_welcome"
+ ON "user_onboarding_status" ("setup_completed_at")
+ WHERE "setup_completed_at" IS NOT NULL AND "welcome_page_created_at" IS NULL;
+--> statement-breakpoint
+
+-- ── バックフィル / Backfill ────────────────────────────────────────────────
+--
+-- このマイグレーションが走った時点で既に存在するユーザーは、旧フローで
+-- セットアップを終えていると見なし `setup_completed_at = NOW()` で記録する。
+-- そうしないと次回ログインで全員が onboarding ウィザードに戻されてしまう。
+-- `welcome_page_created_at` は NULL のままにしておき、`retryWelcomePageIfNeeded`
+-- でログイン時にウェルカムページを生成する余地を残す。
+--
+-- Mark every pre-existing user as "setup completed" so they are not pushed
+-- back through the wizard after this migration lands. Leaving
+-- `welcome_page_created_at` as NULL keeps the login-time retry free to
+-- generate a welcome page lazily.
+INSERT INTO "user_onboarding_status" ("user_id", "setup_completed_at", "created_at", "updated_at")
+SELECT "id", NOW(), NOW(), NOW()
+FROM "user"
+ON CONFLICT ("user_id") DO NOTHING;
diff --git a/server/api/drizzle/meta/_journal.json b/server/api/drizzle/meta/_journal.json
index 920add2d..da9c2c44 100644
--- a/server/api/drizzle/meta/_journal.json
+++ b/server/api/drizzle/meta/_journal.json
@@ -120,6 +120,13 @@
"when": 1777334400000,
"tag": "0017_add_link_type",
"breakpoints": true
+ },
+ {
+ "idx": 17,
+ "version": "7",
+ "when": 1777420800000,
+ "tag": "0018_add_onboarding_and_page_kind",
+ "breakpoints": true
}
]
}
diff --git a/server/api/src/schema/pages.ts b/server/api/src/schema/pages.ts
index 774713e1..77104e61 100644
--- a/server/api/src/schema/pages.ts
+++ b/server/api/src/schema/pages.ts
@@ -1,4 +1,4 @@
-import { pgTable, uuid, text, timestamp, boolean, index } from "drizzle-orm/pg-core";
+import { pgTable, uuid, text, timestamp, boolean, index, uniqueIndex } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { users } from "./users.js";
import { notes } from "./notes.js";
@@ -98,6 +98,20 @@ export const pages = pgTable(
* 部分述語に効くインデックス。
*/
index("idx_pages_note_id").on(table.noteId),
+ /**
+ * オーナーごとに有効なウェルカムページは最大 1 件であることを担保する部分
+ * ユニーク index。`welcomePageService.insertWelcomePage` の `onConflictDoNothing`
+ * が target としてこの index に依拠している。実 DDL は
+ * `drizzle/0018_add_onboarding_and_page_kind.sql` を参照。
+ *
+ * Partial unique index that enforces "at most one live welcome page per
+ * owner". The `onConflictDoNothing` call in
+ * `welcomePageService.insertWelcomePage` targets this exact index. The
+ * actual DDL lives in `drizzle/0018_add_onboarding_and_page_kind.sql`.
+ */
+ uniqueIndex("idx_pages_unique_welcome_per_owner")
+ .on(table.ownerId)
+ .where(sql`${table.kind} = 'welcome' AND ${table.isDeleted} = false`),
],
);