0
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

今回は、前回の記事でアプリケーションに導入したPostgreSQLデータベースを、実際にローカル環境で動かしてみるまでの手順をまとめてみました。
また、RDS Data API経由でAWS環境のデータベース操作にも対応できるように実装してみました。
少しでも参考になれば幸いです。

手順

  1. 必要なパッケージの追加
  2. pnpmスクリプトの追加
  3. マイグレーションSQLファイルの作成
  4. マイグレーションランナーの実装
  5. 初期seedスクリプトの実装
  6. データベースリセット用スクリプトの実装
  7. 動作確認

1. 必要なパッケージの追加

ターミナルで以下のコマンドを実行します。

zsh
# dependenciesにpgを追記
pnpm add pg

# devDependenciesに@types/pgとtsxを追記
pnpm add -D @types/pg tsx

pnpm add
単にnode_modulesに入れるだけでなく、package.jsonの依存欄への追記とpnpm-lock.yamlの更新を一発で行うコマンドです。

pg:PostgreSQLドライバ(ローカル直接接続用)

@types/pg:pgの型定義

tsx:.tsスクリプトを直接実行するランナー

2. pnpmスクリプトの追加

package.jsonにデータベースの初期化用スクリプトコマンドを追加します。

package.json
  "scripts": {
    "db:migrate": "tsx server/db/migrate.ts",
    "db:seed": "tsx server/db/seed.ts",
    "db:reset": "tsx server/db/reset.ts && pnpm db:migrate && pnpm db:seed",
  },

db:resetだけは複合コマンドで、「全削除 → マイグレーション再適用 → seed再投入」をワンコマンドでクリーン状態に戻せるようにしています。

3. マイグレーションSQLファイルの作成

server/db/migrations/に、「ファイル名の数字昇順 = 適用順」となるSQLファイルを並べます。

server/db/migrations/0000_functions.sql
-- ============================================================
-- 0000: 共通関数(updated_at 自動更新トリガ)
-- ------------------------------------------------------------
-- 各テーブルの BEFORE UPDATE トリガから呼ぶ共通関数。
-- 行更新時に updated_at を現在時刻へ自動で書き換える。
-- 正本は docs/rds-design.md「0. 共通: updated_at 自動更新トリガ」。
-- ============================================================

-- 行更新時に updated_at を自動的に現在時刻へ書き換える共通関数
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS trigger AS $$
BEGIN
  NEW.updated_at = now();
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;
server/db/migrations/0001_events.sql
-- ============================================================
-- 0001: events — イベントマスタ
-- ------------------------------------------------------------
-- 現状はスタンプラリーのみ。将来の新種別も type で分岐する。
-- ============================================================

-- イベント(現状はスタンプラリーのみ。将来の新種別もこのテーブルで分岐)のマスタ
CREATE TABLE events (
  id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  code        TEXT NOT NULL,                          -- 外部公開用の安定したコード (例: 'stamp-rally-202606')
  type        TEXT NOT NULL,                          -- イベント種別 (下のCHECK参照)
  name        TEXT NOT NULL,                          -- 管理画面表示用の名称
  starts_at   TIMESTAMPTZ,                            -- 公開開始日時 (任意)
  ends_at     TIMESTAMPTZ,                            -- 公開終了日時 (任意)
  status      TEXT NOT NULL DEFAULT 'draft',          -- draft / active / ended / archived
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now(),

  CONSTRAINT events_code_uk UNIQUE (code),
  -- 種別はCHECKで表現 (新種別の追加はCHECKへ値を足すだけ。ネイティブENUMより柔軟)
  -- 現状はスタンプラリーのみ。新イベント種別が決まったら値を追加する
  CONSTRAINT events_type_chk   CHECK (type IN ('stamp_rally')),
  CONSTRAINT events_status_chk CHECK (status IN ('draft', 'active', 'ended', 'archived')),
  -- 開始・終了が両方ある場合は開始 < 終了を強制 (管理画面の入力ミスを DB で弾く)
  CONSTRAINT events_period_chk CHECK (starts_at IS NULL OR ends_at IS NULL OR starts_at < ends_at)
);

CREATE TRIGGER trg_events_updated_at
  BEFORE UPDATE ON events
  FOR EACH ROW EXECUTE FUNCTION set_updated_at();

その他のマイグレーションファイルは、こちらの記事をご参照ください。

なぜORMのスキーマ生成を使わない?

drizzle-kitの主な仕事は、TypeScriptで書いたスキーマ定義からマイグレーションSQLを自動生成することです。しかし今回はあえて使用していません。

なぜDrizzleスキーマ(TS)からの自動生成を使わないか
→今回のテーブル設計にはORM非親和な構造が含まれているため

  • 高度なCTE(獲得+完了の世代確定ロジック)
  • トリガ
  • 複合外部キー
  • CHECK制約
  • 部分的な非正規化

といったような操作定義は、Drizzleのschema.ts → drizzle-kit generateの表現力の外側にあり、自動生成ではそのまま保持できないため、今回は生SQLを使用しています。
※drizzle-kitのバージョンによっては対応済みのようです。
ただし、Drizzle側のTSスキーマ定義は手書きで別途用意する必要があるというデメリットが発生するため、「CIでのスキーマ差分検証」などを取り入れる必要が出てきます。
また、drizzle-kitmigrateランナーはData API経由で実行する際にそのまま使えない(加工する必要がある)ため、最初から自前ランナーの方が制御しやすいという事情もあります。

マイグレーションファイル概要

  1. 0000_functions.sql
    共通の set_updated_at()関数。各テーブルの更新時にupdated_atを自動で現在時刻にするトリガ用。最初に作る必要がある(後続テーブルのトリガが参照するため)
  2. 0001_events.sql
    イベントマスタ。type/statusをENUMではなくCHECK制約で表現(新種別を足しやすい)
  3. 0002_line_users.sql
    LINE ユーザー(イベント横断で1ユーザー1行、PIIの一元管理点)
  4. 0003_event_participants.sql
    参加(ユーザー × イベント)。eventsとline_usersを参照するので両者の後。
  5. 0004_stamps.sql
    スタンプ定義。flex_message_contentをJSONBで保持。
  6. 0005_user_stamps.sql
    スタンプ獲得状態。event_participantsとstamps両方を参照するので両者の後。
  7. 0006_completion_claims.sql
    スタンプラリー完了の一意確定(完了通知を世代ごとに1回だけ発火させる土台)

4. マイグレーションランナーの実装

server/db/migrations/*.sqlを順に流すランナーを実装します。
※GitHub Actionのワークフローから「Data API」経由で本番環境のデータベース操作を行えるようにした結果、長くなってしまいました。。。(なので参考程度にしていただけると幸いです)
安全に何度でも回せるよう、以下の仕組みを組み込んでいます。

  1. 適用履歴の記録:
    schema_migrationsテーブルに適用済みファイル名を記録し、未適用分だけ流す(冪等)
  2. ロック:
    pg_advisory_lockで多重実行(並行 migrate)を防止
  3. トランザクション:
    1ファイル = 1トランザクション。失敗時は ROLLBACKで中途半端な適用を残さない
  4. 接続情報:
    process.env.DATABASE_URLから読む(未設定なら明示エラー)
  5. Data API対応:
    Data APIは1回のExecuteStatement1文しか実行できません。0000_functions.sqlのようなPL/pgSQL関数定義(本体に ; を含む)を素朴にセミコロン分割すると関数本体が壊れてしまいす。そこで構文を考慮した分割をローカル(pg)でも同じく通すことで、ローカルと本番の挙動を一致させています。

順番が前後ずれてしまい大変申し訳ないのですが、client.tsの内容は次回の記事でご紹介しますので、ぜひ合わせてご参照ください。

server/db/migrate.ts
// ============================================================
// マイグレーションランナー(pg / RDS Data API 二刀流)
// ------------------------------------------------------------
// server/db/migrations/*.sql を「ファイル名の昇順」で適用する。
// 設計書 DDL を正本(plain SQL)とする方針のため、ORM のスキーマ生成には
// 依存せず、SQL ファイルをそのまま実行する。
//
// 特徴:
//   - schema_migrations テーブルで適用済みファイルを記録し、未適用分だけ流す(冪等)。
//   - 各ファイルは 1 トランザクションで適用し、失敗時は ROLLBACK(中途半端な適用を残さない)。
//   - SQL ファイルは無改変のまま、適用時に **メモリ内で文単位に分割**する
//     (Data API は 1 コール 1 文のため)。ローカル/本番で同じ分割を通し挙動を揃える。
//
// ドライバ選択(client.ts と同じ思想):
//   - 既定は pg(node-postgres)。ローカル Docker / CI のテスト DB へ直結。
//   - process.env.DB_DRIVER === 'data-api' のとき RDS Data API(HTTPS / IAM)で
//     Aurora(隔離サブネット)に到達する。非VPC の GitHub Actions からの本番適用用。
//   - runMigrations(connectionString) のように接続文字列を **明示**した場合は
//     常に pg(統合テストの beforeAll がこの形で呼ぶ)。
//
// 接続情報:
//   - pg       : 引数 / process.env.DATABASE_URL
//   - data-api : process.env.DB_CLUSTER_ARN / DB_SECRET_ARN / DB_NAME
//
// 多重実行防止:
//   - pg は pg_advisory_lock で session ロック。
//   - data-api は advisory lock が効かない(セッション固定が保証されない)ため
//     no-op。呼び出し側(GitHub Actions の concurrency グループ)で直列化する。
// ============================================================
import { readdir, readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'

// このスクリプト自身のディレクトリ基準で migrations/ を解決する
const MIGRATIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'migrations')

// pg_advisory_lock 用の任意の固定キー(プロジェクト内で衝突しなければ何でもよい)
const ADVISORY_LOCK_KEY = 947_201_001

// 適用履歴テーブル(両ドライバ共通。単一文なので Data API でも 1 コールで通る)
const SCHEMA_MIGRATIONS_DDL = `CREATE TABLE IF NOT EXISTS schema_migrations (
  filename   TEXT PRIMARY KEY,
  applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`

// 適用対象として許可するファイル名(data-api では INSERT に文字列リテラルで埋め込むため、
// 念のため識別子として安全な形に限定する。readdir で *.sql に絞った上での二重ガード)
const SAFE_FILENAME = /^[\w.-]+\.sql$/

function requireEnv(name: string): string {
  const v = process.env[name]
  if (!v) throw new Error(`環境変数 ${name} が未設定です`)
  return v
}

// ------------------------------------------------------------
// SQL 文分割($$ / 引用符 / コメントを考慮してセミコロンで分割)
// ------------------------------------------------------------
/**
 * SQL を「文の区切り」のセミコロンだけで分割する。以下の内側のセミコロンは
 * 区切りとみなさない:
 *   - 単一引用符文字列 '...'('' で 1 個のクォートをエスケープ)
 *   - 二重引用符の識別子 "..."("" でエスケープ)
 *   - ドル引用符 $$...$$ / $tag$...$tag$(PL/pgSQL 関数本体など)
 *   - 行コメント -- ... (改行まで)
 *   - ブロックコメント(/* で開き対応する閉じで終了、PostgreSQL はネスト可)
 *
 * 区切り文字(;)自体は出力に含めない。前後空白を trim し、空文は捨てる。
 * pg / data-api の両ドライバで同じこの分割を通し、ローカルと本番の挙動を一致させる。
 */
export function splitSqlStatements(sql: string): string[] {
  const statements: string[] = []
  let buf = ''
  let i = 0
  const n = sql.length

  while (i < n) {
    const ch = sql[i]

    // 行コメント: -- から改行まで
    if (ch === '-' && sql[i + 1] === '-') {
      const nl = sql.indexOf('\n', i)
      const end = nl === -1 ? n : nl
      buf += sql.slice(i, end)
      i = end
      continue
    }

    // ブロックコメント: /* ... */(ネスト対応)
    if (ch === '/' && sql[i + 1] === '*') {
      let depth = 1
      buf += '/*'
      let j = i + 2
      while (j < n && depth > 0) {
        if (sql[j] === '/' && sql[j + 1] === '*') {
          depth++
          buf += '/*'
          j += 2
        } else if (sql[j] === '*' && sql[j + 1] === '/') {
          depth--
          buf += '*/'
          j += 2
        } else {
          buf += sql[j]
          j++
        }
      }
      i = j
      continue
    }

    // escape string E'...' / e'...'
    // 通常の '...'(standard_conforming_strings=on)と違い、バックスラッシュが
    // エスケープ文字になる(\' は終端ではない、\\ はリテラルのバックスラッシュ)。
    // 先頭の E/e が識別子の一部でない場合のみ escape string として扱う。
    if ((ch === 'E' || ch === 'e') && sql[i + 1] === "'") {
      const prev = buf.length > 0 ? buf[buf.length - 1]! : ''
      if (!/[A-Za-z0-9_]/.test(prev)) {
        buf += ch // E / e
        buf += "'" // 開きクォート
        i += 2
        while (i < n) {
          // バックスラッシュエスケープ(\' \\ \n 等)は 2 文字まとめて消費する
          if (sql[i] === '\\' && i + 1 < n) {
            buf += sql[i]! + sql[i + 1]!
            i += 2
            continue
          }
          if (sql[i] === "'" && sql[i + 1] === "'") {
            buf += "''"
            i += 2
            continue
          }
          if (sql[i] === "'") {
            buf += "'"
            i++
            break
          }
          buf += sql[i]
          i++
        }
        continue
      }
    }

    // 単一引用符文字列
    if (ch === "'") {
      buf += "'"
      i++
      while (i < n) {
        if (sql[i] === "'" && sql[i + 1] === "'") {
          buf += "''"
          i += 2
          continue
        }
        if (sql[i] === "'") {
          buf += "'"
          i++
          break
        }
        buf += sql[i]
        i++
      }
      continue
    }

    // 二重引用符識別子
    if (ch === '"') {
      buf += '"'
      i++
      while (i < n) {
        if (sql[i] === '"' && sql[i + 1] === '"') {
          buf += '""'
          i += 2
          continue
        }
        if (sql[i] === '"') {
          buf += '"'
          i++
          break
        }
        buf += sql[i]
        i++
      }
      continue
    }

    // ドル引用符 $tag$ ... $tag$
    if (ch === '$') {
      const tag = readDollarTag(sql, i)
      if (tag) {
        const close = sql.indexOf(tag, i + tag.length)
        const end = close === -1 ? n : close + tag.length
        buf += sql.slice(i, end)
        i = end
        continue
      }
    }

    // 文の区切り
    if (ch === ';') {
      const trimmed = buf.trim()
      if (trimmed) statements.push(trimmed)
      buf = ''
      i++
      continue
    }

    buf += ch
    i++
  }

  const tail = buf.trim()
  if (tail) statements.push(tail)
  return statements
}

/**
 * 位置 i が `$tag$`(tag は省略可・数字始まり不可)の開きならその区切り文字列を返す。
 * 例: '$$' / '$func$'。位置パラメータ `$1` 等はドル引用符と誤認しない。
 */
function readDollarTag(sql: string, i: number): string | null {
  // sql[i] === '$' 前提
  let j = i + 1
  while (j < sql.length && /[A-Za-z0-9_]/.test(sql[j]!)) j++
  if (sql[j] !== '$') return null
  const body = sql.slice(i + 1, j)
  // タグ本体が数字始まりは PostgreSQL 仕様上不可(空タグ $$ は可)
  if (body.length > 0 && /^[0-9]/.test(body)) return null
  return sql.slice(i, j + 1)
}

// ------------------------------------------------------------
// ドライバ抽象(pg / data-api)
// ------------------------------------------------------------
interface MigrationDriver {
  /** schema_migrations を作成(存在すれば何もしない) */
  ensureMigrationsTable(): Promise<void>
  /** 適用済みファイル名の集合 */
  appliedFilenames(): Promise<Set<string>>
  /** 1 ファイルを 1 トランザクションで適用し、schema_migrations に記録する */
  applyMigration(filename: string, statements: string[]): Promise<void>
  /** 多重実行防止ロック(pg のみ。data-api は no-op) */
  acquireLock(): Promise<void>
  releaseLock(): Promise<void>
  /** 接続リソースの解放 */
  close(): Promise<void>
}

async function createPgDriver(connectionString: string): Promise<MigrationDriver> {
  const { Client } = await import('pg')
  const client = new Client({ connectionString })
  await client.connect()

  return {
    async ensureMigrationsTable() {
      await client.query(SCHEMA_MIGRATIONS_DDL)
    },
    async appliedFilenames() {
      const res = await client.query<{ filename: string }>('SELECT filename FROM schema_migrations')
      return new Set(res.rows.map((r) => r.filename))
    },
    async applyMigration(filename, statements) {
      try {
        await client.query('BEGIN')
        for (const stmt of statements) await client.query(stmt)
        await client.query('INSERT INTO schema_migrations (filename) VALUES ($1)', [filename])
        await client.query('COMMIT')
      } catch (err) {
        await client.query('ROLLBACK')
        throw new Error(`[migrate] ${filename} の適用に失敗(ロールバック済み): ${String(err)}`)
      }
    },
    async acquireLock() {
      await client.query('SELECT pg_advisory_lock($1)', [ADVISORY_LOCK_KEY])
    },
    async releaseLock() {
      // 接続を閉じれば自動解放されるが明示する
      await client.query('SELECT pg_advisory_unlock($1)', [ADVISORY_LOCK_KEY]).catch(() => {})
    },
    async close() {
      await client.end()
    },
  }
}

async function createDataApiDriver(): Promise<MigrationDriver> {
  const {
    RDSDataClient,
    BeginTransactionCommand,
    CommitTransactionCommand,
    RollbackTransactionCommand,
    ExecuteStatementCommand,
  } = await import('@aws-sdk/client-rds-data')

  const resourceArn = requireEnv('DB_CLUSTER_ARN')
  const secretArn = requireEnv('DB_SECRET_ARN')
  const database = requireEnv('DB_NAME')
  const rds = new RDSDataClient({})

  // 単文実行(トランザクション外 / 内の両用。transactionId 省略時は auto-commit)
  const exec = (sql: string, transactionId?: string) =>
    rds.send(new ExecuteStatementCommand({ resourceArn, secretArn, database, sql, transactionId }))

  return {
    async ensureMigrationsTable() {
      await exec(SCHEMA_MIGRATIONS_DDL)
    },
    async appliedFilenames() {
      // JSON 整形で受け取り、列メタデータの手組みを避ける
      const res = await rds.send(
        new ExecuteStatementCommand({
          resourceArn,
          secretArn,
          database,
          sql: 'SELECT filename FROM schema_migrations',
          formatRecordsAs: 'JSON',
        }),
      )
      const rows = res.formattedRecords
        ? (JSON.parse(res.formattedRecords) as { filename: string }[])
        : []
      return new Set(rows.map((r) => r.filename))
    },
    async applyMigration(filename, statements) {
      const begun = await rds.send(new BeginTransactionCommand({ resourceArn, secretArn, database }))
      const transactionId = begun.transactionId!
      try {
        for (const stmt of statements) await exec(stmt, transactionId)
        // filename は SAFE_FILENAME 検証済み。Data API はトランザクション内も 1 コール 1 文
        await exec(`INSERT INTO schema_migrations (filename) VALUES ('${filename}')`, transactionId)
        await rds.send(new CommitTransactionCommand({ resourceArn, secretArn, transactionId }))
      } catch (err) {
        await rds
          .send(new RollbackTransactionCommand({ resourceArn, secretArn, transactionId }))
          .catch(() => {})
        throw new Error(`[migrate] ${filename} の適用に失敗(ロールバック済み): ${String(err)}`)
      }
    },
    async acquireLock() {
      // no-op: Data API では advisory lock が効かない。GitHub Actions の
      // concurrency グループ(db-ops-<env>)で同一環境の多重実行を直列化する。
    },
    async releaseLock() {
      // no-op
    },
    async close() {
      rds.destroy()
    },
  }
}

// ------------------------------------------------------------
// 共通オーケストレーション
// ------------------------------------------------------------
async function applyPending(driver: MigrationDriver): Promise<string[]> {
  await driver.acquireLock()
  try {
    await driver.ensureMigrationsTable()
    const applied = await driver.appliedFilenames()

    const files = (await readdir(MIGRATIONS_DIR))
      .filter((f) => f.endsWith('.sql'))
      .sort((a, b) => a.localeCompare(b))

    const pending = files.filter((f) => !applied.has(f))
    if (pending.length === 0) {
      console.log('[migrate] 適用すべき新規マイグレーションはありません')
      return []
    }

    for (const file of pending) {
      if (!SAFE_FILENAME.test(file)) {
        throw new Error(`[migrate] 不正なマイグレーションファイル名: ${file}`)
      }
      const raw = await readFile(join(MIGRATIONS_DIR, file), 'utf8')
      const statements = splitSqlStatements(raw)
      console.log(`[migrate] 適用中: ${file}${statements.length} 文)`)
      await driver.applyMigration(file, statements)
    }

    console.log(`[migrate] 完了: ${pending.length} 件のマイグレーションを適用しました`)
    return pending
  } finally {
    await driver.releaseLock()
  }
}

/**
 * 未適用のマイグレーションを順に適用する(冪等)。
 *
 * @param connectionString 指定すると **常に pg** で接続する(統合テスト用)。
 *   省略時は process.env.DB_DRIVER で pg / data-api を選択(既定 pg)。
 * @returns 今回適用したファイル名の配列(無ければ空配列)
 */
export async function runMigrations(connectionString?: string): Promise<string[]> {
  const driverKind = connectionString ? 'pg' : (process.env.DB_DRIVER ?? 'pg')

  let driver: MigrationDriver
  if (driverKind === 'data-api') {
    driver = await createDataApiDriver()
  } else {
    const conn = connectionString ?? process.env.DATABASE_URL
    if (!conn) {
      throw new Error('DATABASE_URL が未設定です(compose の dev サービス、または .env を確認)')
    }
    driver = await createPgDriver(conn)
  }

  try {
    return await applyPending(driver)
  } finally {
    await driver.close()
  }
}

// CLI として直接実行された時だけ走らせる(import 時には実行しない)。
const isCli = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
if (isCli) {
  runMigrations()
    .then(() => process.exit(0))
    .catch((err) => {
      console.error(err)
      process.exit(1)
    })
}

5. 初期seedスクリプトの実装

開発用の最低限のマスタを投入します。

server/db/seed.ts
// ============================================================
// マスタ seed 投入(冪等 / pg・Data API 二刀流)
// ------------------------------------------------------------
// スタンプラリーのマスタを投入する:
//   - events: スタンプラリー 1 件(active)
//   - stamps: 現状 2 つ(flex_message_content 付き)
//
// 接続は client.ts の getDb()(DB_DRIVER で pg / data-api を切替)を使うため、
// ローカル(Docker PostgreSQL)でも本番/stg(Aurora Data API)でも同じコードで動く。
// CLI(pnpm db:seed)からも、DB 操作ワークフロー(db-operations.yml の seed)からも実行する。
//
// 冪等性の方針(ON CONFLICT):
//   - 「定義(表示内容)」は再実行で更新する: events.name / stamps.name /
//     stamps.display_order / stamps.flex_message_content。
//     → Flex Message JSON を更新したいときに再実行で差し替えられる。
//   - 「運用状態」は初回のみで上書きしない: events.status / 期間 / stamps.is_active。
//     → 管理側で archived 化・非公開化した状態を再 seed で巻き戻さない。
//   - BEFORE UPDATE トリガにより、DO UPDATE 時は updated_at が自動更新される。
// ============================================================
import { pathToFileURL } from 'node:url'
import { sql } from 'drizzle-orm'
import { closeDb, getDb } from './client'

// スタンプラリーイベントの安定コード(外部公開用)
const EVENT_CODE = 'stamp-rally-202606'

// 現状のスタンプ定義(チャプター2で行を増やすだけで拡張できる)
const STAMPS: ReadonlyArray<{
  code: string
  name: string
  displayOrder: number
  flexMessageContent: unknown
}> = [
  {
    code: '001',
    name: '第1スタンプ',
    displayOrder: 1,
    // 実際の Flex Message JSON に差し替える。ここでは構造確認用の最小ダミー。
    flexMessageContent: {
      type: 'bubble',
      body: {
        type: 'box',
        layout: 'vertical',
        contents: [{ type: 'text', text: '第1スタンプを獲得しました' }],
      },
    },
  },
  {
    code: '002',
    name: '第2スタンプ',
    displayOrder: 2,
    flexMessageContent: {
      type: 'bubble',
      body: {
        type: 'box',
        layout: 'vertical',
        contents: [{ type: 'text', text: '第2スタンプを獲得しました' }],
      },
    },
  },
]

export async function seed(): Promise<void> {
  const db = await getDb()

  // event と stamps を 1 トランザクションで投入する。途中失敗で event だけ/
  // 一部 stamp だけ更新された半端なマスタが公開されないよう、全体を不可分にする。
  await db.transaction(async (tx) => {
    // イベント(冪等)。name は再投入で更新するが、status 等の運用状態は初回のみ。
    await tx.execute(sql`
      INSERT INTO events (code, type, name, status)
      VALUES (${EVENT_CODE}, 'stamp_rally', 'スタンプラリー 2026/06', 'active')
      ON CONFLICT (code) DO UPDATE SET name = EXCLUDED.name
    `)

    // 後段の stamps 投入で参照する event_id を確実に取得する
    const rows = await tx.execute<{ id: string }>(sql`SELECT id FROM events WHERE code = ${EVENT_CODE}`)
    const eventId = rows[0]?.id
    if (eventId == null) {
      throw new Error('イベントの作成/取得に失敗しました')
    }

    // スタンプ(イベント内 code で冪等)。表示定義は再投入で更新、is_active は初回のみ。
    for (const s of STAMPS) {
      await tx.execute(sql`
        INSERT INTO stamps (event_id, code, name, display_order, is_active, flex_message_content)
        VALUES (${eventId}, ${s.code}, ${s.name}, ${s.displayOrder}, true, ${JSON.stringify(s.flexMessageContent)}::jsonb)
        ON CONFLICT (event_id, code) DO UPDATE SET
          name = EXCLUDED.name,
          display_order = EXCLUDED.display_order,
          flex_message_content = EXCLUDED.flex_message_content
      `)
    }
  })

  console.log(`[seed] 完了: event(${EVENT_CODE}) + stamps ${STAMPS.length} 件を投入/更新しました`)
}

// CLI として直接実行された時だけ走らせる(import 時には実行しない)。
const isCli = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
if (isCli) {
  seed()
    .then(() => closeDb())
    .then(() => process.exit(0))
    .catch(async (err) => {
      console.error(err)
      await closeDb().catch(() => {})
      process.exit(1)
    })
}

このスクリプトでは、イベント1件(stamp-rally-202606, status=active)とスタンプ2件(FlexMessageのダミーJSON付き)を投入しています。
すべてON CONFLICT ... DO NOTHINGで冪等性を保証しているため、何度実行しても重複しません。
line_users / event_participants / user_stampsはあえてseedしません(これらは実際のLIFF認証フロー後に検証済みline_user_idで作られるデータのため)

6. データベースリセット用スクリプトの実装

データベースを一度リセットしたい場合に実行するスクリプトです。
publicスキーマを丸ごと落として作り直す破壊的スクリプトのため、事故防止のためのガードを入れています。

  • 接続先ホストがlocalhost / 127.0.0.1 / db(composeサービス名)/ ::1以外なら拒否 → 本番環境を誤って吹き飛ばすのを防止
  • どうしても実行したい場合のみALLOW_DB_RESET=1を付ける設計(本番環境操作時)
server/db/reset.ts
// ============================================================
// DB リセット(public スキーマ全削除 → 再作成 / pg・Data API 二刀流)
// ------------------------------------------------------------
// public スキーマを丸ごと落として作り直す。マイグレーション/seed を
// クリーンな状態からやり直したい時に使う(破壊的)。
// db:reset スクリプトは「reset → migrate → seed」をこの順で連続実行する。
//
// 安全ガード(事故防止。破壊操作なので二重に守る):
//   - pg(ローカル): 接続先ホストが localhost / 127.0.0.1 / db(compose サービス名)
//     以外なら拒否。強制したい場合のみ ALLOW_DB_RESET=1。
//   - data-api(本番/stg): 必ず ALLOW_DB_RESET=1 を要求する。Data API は隔離 Aurora を
//     確実に消せてしまうため、明示的なオプトイン無しでは絶対に走らせない。
//     本番では db-operations.yml が「main ブランチ限定」+「対象環境名の typed
//     confirmation」を通した上で、reset ステップのみ ALLOW_DB_RESET=1 を立てて呼ぶ。
//
// 接続情報:
//   - pg       : process.env.DATABASE_URL
//   - data-api : process.env.DB_CLUSTER_ARN / DB_SECRET_ARN / DB_NAME
// ============================================================

// ローカルとみなすホスト名(compose ネットワークのサービス名 db を含む)
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', 'db', '::1'])

function requireEnv(name: string): string {
  const v = process.env[name]
  if (!v) throw new Error(`環境変数 ${name} が未設定です`)
  return v
}

function assertLocalOrForced(connectionString: string) {
  if (process.env.ALLOW_DB_RESET === '1') return
  let host = ''
  try {
    host = new URL(connectionString).hostname
  } catch {
    // URL として解釈できない場合も安全側に倒して中断
  }
  if (!LOCAL_HOSTS.has(host)) {
    throw new Error(
      `db:reset はローカル DB 専用です(接続先ホスト: '${host}')。` +
        '本当に実行する場合のみ ALLOW_DB_RESET=1 を付けてください。',
    )
  }
}

async function resetViaPg() {
  const { Client } = await import('pg')
  const connectionString = requireEnv('DATABASE_URL')
  assertLocalOrForced(connectionString)

  const client = new Client({ connectionString })
  await client.connect()
  try {
    // public スキーマごと破棄して作り直す(テーブル・ビュー・関数・トリガを一掃)
    await client.query('DROP SCHEMA public CASCADE')
    await client.query('CREATE SCHEMA public')
    console.log('[reset] public スキーマを再作成しました(migrate / seed をやり直してください)')
  } finally {
    await client.end()
  }
}

async function resetViaDataApi() {
  // data-api は本番/stg の隔離 Aurora を消し得るため、明示オプトイン必須
  if (process.env.ALLOW_DB_RESET !== '1') {
    throw new Error(
      'data-api での db:reset は破壊的です。意図的な実行時のみ ALLOW_DB_RESET=1 を付けてください' +
        '(本番は db-operations.yml の reset 操作経由で実行すること)。',
    )
  }

  const {
    RDSDataClient,
    BeginTransactionCommand,
    CommitTransactionCommand,
    RollbackTransactionCommand,
    ExecuteStatementCommand,
  } = await import('@aws-sdk/client-rds-data')

  const resourceArn = requireEnv('DB_CLUSTER_ARN')
  const secretArn = requireEnv('DB_SECRET_ARN')
  const database = requireEnv('DB_NAME')
  const rds = new RDSDataClient({})

  // DROP → CREATE を 1 トランザクションで実施(中断時に public 不在のまま残さない)
  const begun = await rds.send(new BeginTransactionCommand({ resourceArn, secretArn, database }))
  const transactionId = begun.transactionId!
  try {
    await rds.send(
      new ExecuteStatementCommand({
        resourceArn,
        secretArn,
        database,
        transactionId,
        sql: 'DROP SCHEMA public CASCADE',
      }),
    )
    await rds.send(
      new ExecuteStatementCommand({
        resourceArn,
        secretArn,
        database,
        transactionId,
        sql: 'CREATE SCHEMA public',
      }),
    )
    await rds.send(new CommitTransactionCommand({ resourceArn, secretArn, transactionId }))
    console.log('[reset] public スキーマを再作成しました(migrate / seed をやり直してください)')
  } catch (err) {
    await rds
      .send(new RollbackTransactionCommand({ resourceArn, secretArn, transactionId }))
      .catch(() => {})
    throw err
  } finally {
    rds.destroy()
  }
}

async function main() {
  const driver = process.env.DB_DRIVER ?? 'pg'
  if (driver === 'data-api') {
    await resetViaDataApi()
  } else {
    await resetViaPg()
  }
}

main().catch((err) => {
  console.error(err)
  process.exit(1)
})

7. 動作確認

ターミナルでコンテナを立ち上げます。

zsh
docker compose up --build

別のターミナルを開き、「コンテナ内で」コマンドを実行します。

zsh
# マイグレーション
docker compose exec dev pnpm db:migrate

# 初期seedデータ投入
docker compose exec dev pnpm db:seed

コンテナ内のpsqlに入ります。

zsh
docker compose exec db psql -U app -d project-name

以下のテーブル一覧を表示させるコマンドを実行します。

zsh
project-name=# \dt

以下のように出力されれば問題ありません。

zsh
                      List of relations
 Schema |                Name                 | Type  | Owner 
--------+-------------------------------------+-------+-------
 public | event_participants                  | table | app
 public | events                              | table | app
 public | line_users                          | table | app
 public | schema_migrations                   | table | app
 public | stamp_rally_event_completion_claims | table | app
 public | stamps                              | table | app
 public | user_stamps                         | table | app
(7 rows)

今回は以上になります!

0
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?