From 240d18d82cf34a7e3efd20face9605cec07a0631 Mon Sep 17 00:00:00 2001 From: otomatty Date: Sun, 26 Apr 2026 20:05:16 +0900 Subject: [PATCH 1/2] fix(api): add missing 0018 migration for onboarding & pages.kind PR #728 added the userOnboardingStatus table and the pages.kind column to the Drizzle TS schema but never produced a matching migration under server/api/drizzle/. CI runs only `bunx drizzle-kit migrate`, so the legacy SQL placed in db/migrations/005_*.sql was never applied to either dev or prod, leaving production with `relation "user_onboarding_status" does not exist` and `column "kind" of relation "pages" does not exist` (500 on GET /api/onboarding/status and POST /api/pages). This commit: - Adds server/api/drizzle/0018_add_onboarding_and_page_kind.sql with the pages.kind column + CHECK, the partial unique index used by welcomePageService.onConflictDoNothing, the user_onboarding_status table, the retry-scan partial index, and a backfill that marks existing users as setup-complete so they are not pushed back through the wizard. - Mirrors the partial unique index idx_pages_unique_welcome_per_owner in the TS schema for documentation parity. - Removes the legacy db/migrations/ directory entirely; CI never touched it and leaving it around invites the same regression. README is updated to point at server/api/drizzle/ as the single source of truth. Re-fix-prevention: - New CI job drizzle-migration-check (PR-only) runs scripts/check-drizzle-migrations.mjs, which fails when a PR modifies any server/api/src/schema/**/*.ts file without adding a matching server/api/drizzle/NNNN_*.sql and updating _journal.json. PRs that truly do not need a DB migration can opt out with [skip drizzle-check]. - AGENTS.md / CLAUDE.md document the schema/migration pairing rule and the develop->dev / main->prod auto-apply pipeline. Made-with: Cursor --- .github/workflows/ci.yml | 30 +++ AGENTS.md | 16 ++ CLAUDE.md | 5 + README.md | 4 +- db/migrations/001_add_notes_tables.sql | 49 ----- db/migrations/002_add_page_snapshots.sql | 19 -- db/migrations/003_add_invitation_tokens.sql | 33 --- .../004_add_invitation_locale_tracking.sql | 18 -- .../005_add_onboarding_and_page_kind.sql | 69 ------ scripts/check-drizzle-migrations.mjs | 201 ++++++++++++++++++ .../0018_add_onboarding_and_page_kind.sql | 114 ++++++++++ server/api/drizzle/meta/_journal.json | 7 + server/api/src/schema/pages.ts | 16 +- 13 files changed, 390 insertions(+), 191 deletions(-) delete mode 100644 db/migrations/001_add_notes_tables.sql delete mode 100644 db/migrations/002_add_page_snapshots.sql delete mode 100644 db/migrations/003_add_invitation_tokens.sql delete mode 100644 db/migrations/004_add_invitation_locale_tracking.sql delete mode 100644 db/migrations/005_add_onboarding_and_page_kind.sql create mode 100644 scripts/check-drizzle-migrations.mjs create mode 100644 server/api/drizzle/0018_add_onboarding_and_page_kind.sql 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..bed33eda --- /dev/null +++ b/scripts/check-drizzle-migrations.mjs @@ -0,0 +1,201 @@ +#!/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=AM ...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]` を含めるか、コミットメッセージに同じ文字列を + * 含めて CI から `--allow-skip-marker` 経由で許容できる(環境変数 + * `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 || ""; +} + +/** + * リモート参照が存在するか確認し、無ければ no-op で returns する。 + * Pull request CI 以外(push event など)で base が解決できない場合の保険。 + */ +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=AMR", `${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; + return path.endsWith(".ts"); +} + +/** + * 新規マイグレーション SQL が追加されたか、または journal が更新されたか。 + * Either a new migration SQL was added or 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)) { + 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`), ], ); From 8d79efe7f8254d3d913d250b302aca90b1dd46f8 Mon Sep 17 00:00:00 2001 From: otomatty Date: Sun, 26 Apr 2026 20:19:31 +0900 Subject: [PATCH 2/2] fix: address PR #755 review comments Tighten the Drizzle migration consistency checker by detecting deleted schema files, honoring the documented types/ exclusion, failing fast in CI when the diff base is unavailable, and aligning JSDoc with the required SQL-plus-journal policy. Made-with: Cursor --- scripts/check-drizzle-migrations.mjs | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/scripts/check-drizzle-migrations.mjs b/scripts/check-drizzle-migrations.mjs index bed33eda..a3090ea8 100644 --- a/scripts/check-drizzle-migrations.mjs +++ b/scripts/check-drizzle-migrations.mjs @@ -15,11 +15,11 @@ * * 仕組み / How it works: * - 比較ベースを決める(環境変数 `DRIZZLE_DIFF_BASE` または既定で `origin/develop`)。 - * - `git diff --name-only --diff-filter=AM ...HEAD` でスキーマ変更を抽出。 + * - `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` の変更があるか確認する。 + * `server/api/drizzle/meta/_journal.json` の変更が両方あるか確認する。 * - スキーマ変更があるのにマイグレーションが追加されていなければ exit 1。 * * Compute the diff between HEAD and the configured base (default @@ -35,7 +35,7 @@ * コメントだけ・JSDoc だけのスキーマ TS 変更や、CHECK 制約に影響しない型の * 別名導入など「DB に当てる必要がない」差分の場合は、PR メッセージに * `[skip drizzle-check]` を含めるか、コミットメッセージに同じ文字列を - * 含めて CI から `--allow-skip-marker` 経由で許容できる(環境変数 + * 含めることで許容できる(環境変数 * `DRIZZLE_SKIP_MARKER` でも可)。 */ @@ -72,8 +72,8 @@ function git(args) { } /** - * リモート参照が存在するか確認し、無ければ no-op で returns する。 - * Pull request CI 以外(push event など)で base が解決できない場合の保険。 + * リモート参照が存在するか確認する。 + * Pull request CI では base が解決できない場合に fail fast する。 */ function ensureBaseExists(base) { const result = spawnSync("git", ["rev-parse", "--verify", "--quiet", base], { @@ -88,7 +88,7 @@ function ensureBaseExists(base) { * @returns {string[]} */ function changedPaths(base) { - const out = git(["diff", "--name-only", "--diff-filter=AMR", `${base}...HEAD`]); + const out = git(["diff", "--name-only", "--diff-filter=ADMR", `${base}...HEAD`]); return out .split("\n") .map((s) => s.trim()) @@ -114,12 +114,13 @@ 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 が更新されたか。 - * Either a new migration SQL was added or the journal was modified. + * 新規マイグレーション SQL が追加され、かつ journal も更新されたか。 + * Whether both a new migration SQL was added AND the journal was modified. * * @param {string[]} added * @param {string[]} changed @@ -148,6 +149,13 @@ 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).`, );