AWS への移行と聞くと、まず VPC を作ったり RDS を立てたりする場面を思い浮かべると思います。でも、私がいちばん最初に手を付けたのは AWS ではありませんでした。アプリが DB と話すやり方を、DB は Supabase のまま入れ替える作業です。

このアプリは、DB を読み書きするのに Supabase の公式ライブラリ supabase-js を使っていました。AWS の RDS にはこれの相手(PostgREST)がいないので、移すならどこかで書き換えが要ります。今回は、その書き換えを AWS のアカウントを作る前に終わらせ、本番で確かめてから次へ進みました。

第1回は、なぜこれを最初にやったのか、RLS(行ごとの読み書きを DB が判定する仕組み)を 1 本も書き換えずに残した方法、新旧の結果を突き合わせて確かめたやり方、そして移行のおかげで見つかった元からの不具合 5 件を書きます。

この記事でわかること:

  • DB の引っ越しより先に「アプリと DB のあいだ」を入れ替えた理由
  • アプリから直接 DB につないでも RLS を効かせ続ける withUser() の仕組み
  • 書き込みを 1 つのトランザクションにまとめると何が良くなるか
  • 元の実装と新しい実装の結果を突き合わせる確かめ方(読み取り 125 通り・書き込み 273 回の操作)
  • 書き換えで見つかった不具合 5 件と、つまずいた所
CONTENTS目次
  1. なぜ DB より先に「あいだ」を替えるのか
  2. 何をどの順で書き換えたか
  3. withUser:RLS をそのまま効かせる仕組み
  4. 書き込みは 1 つのトランザクションに
  5. 新旧の結果を突き合わせて確かめる
  6. 移行で見つかった元からの不具合 5 件
  7. つまずいた所
  8. 試験のツボ
  9. まとめ

なぜ DB より先に「あいだ」を替えるのか

第0回で書いたとおり、Supabase から離れるときの本当の難所はログインではなく、DB の呼び出し方でした。supabase-js は、裏で PostgREST(DB のテーブルを HTTP の API として見せるサーバー)に問い合わせています。Supabase ではこれが最初から用意されていますが、RDS にはありません。

書き換える先には Drizzle(TypeScript で型付きの SQL を書ける ORM)を選びました。ORM は「DB の表を、プログラムのオブジェクトのように扱うための道具」です。Drizzle は PostgreSQL に直接つなぐので、相手が Supabase でも RDS でも同じコードで動きます。

ここが大事なところで、Drizzle は Supabase の PostgreSQL にもそのままつなげます。つまり、DB は今の Supabase のまま、アプリ側の書き換えだけを先に本番へ出せます。こうすると次の良いことがありました。

  • いちばん重い作業を、いつでも戻せる状態で本番確認できる。問題が出たら、前のコミットに戻すだけで元どおり
  • 一度に変えるものが一つになる。DB の引っ越し(第5回)のときは、アプリ側はもう確かめ済み。そこで何か起きれば、原因は DB の側だと絞れる
  • AWS のアカウントが無くても始められる。実際、アカウントを作ったのはこの作業が終わった日でした

規模は、読み取りが約 45 関数(24 ファイル)、書き込み(Server Actions)が約 20 ファイル、それに帳票の共有リンクの閲覧ページです。期間は 10 月 4 日〜12 日でした。

コードの書き換えの多くは Claude Code に任せ、私はレビューと、Vercel の環境変数の登録、本番 DB につないだプレビュー環境での画面確認を担当しました。

何をどの順で書き換えたか

一度に全部書き換えると、壊れたときにどこが原因か分かりません。そこで 5 つに分けて、一つ終わるたびに本番へ出しました。

手順 中身 本番に出した日
1-0 土台 Drizzle の接続と withUser()。いちばん小さな画面(領収書の取得先の一覧)だけ切り替え 10/4
1-1 読み取り データ層の読み取り 24 ファイル・約 45 関数 10/12
1-2 書き込み Server Actions の書き込み約 20 ファイル(請求番号の採番を含む) 10/12
1-3 残り 帳票の共有リンク(ログインなしで見る)・音楽・写真 10/12
1-4 片付け 比べるために残していた元の実装と比較ページを削除 10/12 以降

テーブルの定義は、これまでどおり Supabase のマイグレーション(SQL ファイル)を正とし、Drizzle 側の定義は drizzle-kit pull で DB から生成するだけにしました。手で書く定義が 2 つあると、いつか食い違うからです。

書き換えの前後で、コードはこう変わります。領収書の取得先の一覧を引く部分です。

// 前(supabase-js)
const { data, error } = await supabase
  .from("receipt_sources")
  .select("id, name, url, cycle, last_fetched_on, expense_categories(name)")
  .is("deleted_at", null)
  .order("last_fetched_on", { ascending: true, nullsFirst: true });
if (error) {
  console.error("[getReceiptSources]", error.message);
  return [];
}

// 後(Drizzle)
const rows = await withUser(userId, (tx) =>
  tx.query.receiptSources.findMany({
    columns: { id: true, name: true, url: true, cycle: true, lastFetchedOn: true },
    with: { expenseCategory: { columns: { name: true } } },
    where: (t, { isNull }) => isNull(t.deletedAt),
    orderBy: (t, { sql }) => [sql`${t.lastFetchedOn} asc nulls first`],
  }),
);

見た目はあまり変わりませんが、後のほうは列の名前が型で検査されます。存在しない列を書くと、動かす前にエラーになります。これが、あとで書く「見つかった不具合」につながりました。

withUser:RLS をそのまま効かせる仕組み

RLS は何を見ているのか

このアプリでは、他人のデータが見えないことを DB の RLS で守っています。ポリシーは 24 テーブルに 67 本ありますが、ほとんどは最初のマイグレーションでまとめて作った同じ形です。

execute format('create policy %1$s_select on %1$s for select using (auth.uid() = user_id);', t);
execute format('create policy %1$s_insert on %1$s for insert with check (auth.uid() = user_id);', t);

「その行の user_id が、いまログインしている人(auth.uid())と同じなら読める・書ける」という意味です。では auth.uid() は何を返しているのか。Supabase の中身を見ると、request.jwt.claims という設定値(ログインした人の情報を JSON で入れたもの)の sub を読んで返しているだけでした。この値は、PostgREST が問い合わせのたびに立てています。

ということは、PostgREST を通らなくても、アプリが自分で同じ値を立てれば RLS はそのまま効くはずです。

直接つなぐと、RLS は素通りになる

ここで一つ大きな落とし穴があります。アプリが DB につなぐときのユーザー(postgres)は、テーブルの持ち主なので RLS を素通りします。何もしないで問い合わせると、他人の行まで全部見えます。

そこで、問い合わせはすべて withUser() という関数を通すことにしました。

export async function withUser<T>(userId: string, fn: (tx: Tx) => Promise<T>): Promise<T> {
  const claims = JSON.stringify({ sub: userId, role: "authenticated" });
  return getDb().transaction(async (tx) => {
    await tx.execute(
      sql`select set_config('role', 'authenticated', true), set_config('request.jwt.claims', ${claims}, true)`,
    );
    return fn(tx);
  });
}

やっていることは 3 つです。

  • トランザクションを始める
  • その中で、ロールを authenticated(ログインした人用の、RLS が効くロール)に切り替え、request.jwt.claims にユーザー ID を入れる
  • 渡された問い合わせ(fn)を、その中で走らせる

set_config の 3 つ目の引数 true は「このトランザクションの中だけ」という意味です。終われば元に戻るので、接続を使い回しても次の人に前の人の ID が残りません。ここは大事で、Supabase の接続口(Transaction pooler)は、トランザクションごとに接続を貸し借りする仕組みだからです。

ログインしていない人のための asAnon()(ロールを anon にするだけ)も用意しました。帳票の共有リンクの閲覧ページはこちらを使います。閲覧する人がたまたまログインしていても、取引先と同じ結果になるようにするためです。

素の接続は export しない

この仕組みの弱点は、withUser() を通し忘れた 1 か所で RLS が効かなくなることです。そこで、DB の接続そのもの(db)はモジュールの外に出さないことにしました。外から使えるのは withUser() と asAnon() だけなので、通し忘れようがありません。

ログイン中のユーザー ID も、getCurrentUserId() という関数 1 か所からだけ取るようにしました。第9回で Cognito に替えたときに書き換えたのは、ほぼこの関数だけです。

こうして、67 本のポリシーを 1 本も書き換えずに済みました。RDS に移ったあとも、auth.uid() と同じ振る舞いの関数を置けば同じ形で動きます(第5回)。

並べたいときは withUser を並べる

1 つの withUser() の中の問い合わせは、1 本の接続で順番に走ります。ダッシュボードのように問い合わせが多い画面では、withUser() を複数作って Promise.all で同時に投げています。画面の速さはこれに依存しています。

書き込みは 1 つのトランザクションに

supabase-js では、1 つの操作の中の書き込みが、それぞれ別の HTTP リクエストでした。たとえば請求書を作るときは「番号を採番する → 請求書を入れる → 明細を入れる」の 3 回です。途中で明細だけ失敗すると、明細の無い請求書と、進んだ番号が残りえました。

Drizzle に移すときに、1 つの操作の書き込みは 1 つの withUser()=1 つのトランザクションにまとめました。途中で失敗すれば、番号も含めて全部戻ります。

let id: string;
try {
  id = await withUser(userId, async (tx) => {
    const [r] = await tx.execute(sql`select next_document_number(${"invoice"}, ${year}) as seq`);
    const [created] = await tx.insert(schema.invoices).values({ /* 本体 */ })
      .returning({ id: schema.invoices.id });
    await tx.insert(schema.invoiceItems).values(/* 明細 */); // 失敗したら番号ごと戻る
    return created.id;
  });
} catch (e) {
  return { error: "保存に失敗しました" };
}
redirect(`/invoices/${id}`); // トランザクションの外で呼ぶ

注意点が 2 つありました。

  • redirect() はトランザクションの外で呼ぶ。Next.js の redirect() は、内部で特別な例外を投げて画面を移します。トランザクションの中で呼ぶと、それを「失敗」と受け取ってロールバックしてしまいます
  • 「失敗してもログだけ」だった付随の書き込みは、セーブポイントの中で流す。たとえば見積を受注にしたときに作る定期請求のひな形は、作れなくても受注は止めない作りでした。これは入れ子のトランザクション(tx.transaction)の中で流し、本体を巻き込まないようにしました

振る舞いが変わった所もあります。取引先を作るときに担当者の保存だけ失敗すると、前は取引先だけが保存されていましたが、今は両方とも保存されません。こちらのほうが正しいと判断し、エラーの文言もそれに合わせました。

新旧の結果を突き合わせて確かめる

この作業のいちばんの山場は、「書き換えたあとも、前と同じ結果が返る」をどう確かめるかでした。画面を目で見て回るだけでは、絞り込みの組み合わせや細かい日付の形の違いまでは追えません。

そこで、元の実装を消さずに写しとして残し、同じ条件で新旧の両方を呼んで、結果を機械的に突き合わせることにしました。比べる仕組みは、手元の開発環境だけで動くページとして作っています。

読み取り:125 通りの JSON を比べる

  • 元の実装(supabase-js)の写しと新しい実装(Drizzle)を、同じログインで呼ぶ
  • 一覧は絞り込みを 9 通り変え、詳細は存在しない ID も含める。全部で 125 通り
  • 返ってきた JSON をそのまま比べる

テストデータは手元の Supabase(supabase start で動かすローカルの DB)に入れました。ここで工夫したのが、自分のデータに加えて【他人】のデータを混ぜたことです。新しい実装の結果に【他人】が 1 件でも出てきたら、RLS の漏れとして検出できます。論理削除した行や期限を過ぎた請求書など、差が出やすい形のデータも入れています。

結果は 125 / 125 一致、他人の行の混入なし。このあと、本番の DB につないだプレビュー環境で主な画面を開き、本番と同じデータが見えることを私が目で確かめてから main に出しました。

書き込み:全テーブルの行を比べる

書き込みは、返り値ではなく操作のあとの DB の中身を比べます。1 つのシナリオごとに次の順で流しました。

  1. テストデータを入れ直す
  2. 元の実装で操作する(請求書を作る・入金を登録する・見積を受注にする、など)
  3. 全テーブルの行を撮る
  4. もう一度テストデータを入れ直す
  5. 新しい実装で同じ操作をする
  6. 全テーブルの行を撮って、3 と比べる

新しく作られた行の ID や作成日時は、毎回違う値になります。そのままでは比べられないので、ラベル(「請求書 1」「明細 3」のような名前)に置き換えてから比べました。【他人】の行も撮る対象に入れているので、他人の行を書き換えてしまった場合も差として出ます。

結果は、20 シナリオ・273 回の操作が 3 回続けて全部一致しました(書き込みの本体を移した時点では 16 シナリオ・約 230 回で、5 回続けて一致)。

対象 比べ方 件数 結果
読み取り 新旧の返り値(JSON) 125 通り すべて一致・他人の行なし
書き込み 操作後の全テーブルの行 20 シナリオ・273 回の操作 3 回続けてすべて一致
共有リンクの閲覧ページ 有効・期限切れ・停止・存在しない × ログインあり・なし 8 通り 見える文字が完全に一致
RLS 検証スクリプト(他人の行が見えない・書けない、前の人の ID が残らない、など) 10 項目 すべて通過
元の実装と新しい実装でそれぞれテストデータを入れて操作し全テーブルを撮り、ID と日時をラベルに置き換えて突き合わせる流れ図
書き込みは、新旧それぞれで操作したあとの全テーブルを撮り、ID と日時をラベルにそろえてから突き合わせた

先に「わざと壊した版」で試す

比べる仕組みを作ったら、本番の比較に使う前にわざと壊した版で検出できるかを確かめました。見積を削除する操作を、こっそり経費の削除にすり替えた版を用意して流し、その 1 か所だけが差として出ることを見てから使い始めています。

これは、実験でいう陰性対照のようなものです。「全部一致した」という結果だけを見ていると、そもそも何も比べていない比較(たとえば両方とも空のテーブルを撮っている)に気づけません。壊したら気づく、を先に確かめておくと、一致の結果を信用できます。

移行で見つかった元からの不具合 5 件

書き換えの途中で、移行とは関係なく元から壊れていた所が 5 件見つかりました。

# どこ 症状 原因
1 記録を絵にして見せる「庭」の画面 入金を数えていない 入金のテーブルに無い列(deleted_at)で絞っていた。問い合わせが毎回エラーになり、空として扱われていた
2 カレンダー 見積の期限が出ない 見積のテーブルに無い列(subject)を引いていた。これも毎回エラー
3 見積・請求書の保存 小数の数量で保存できない 数量×単価を円に丸めていなかった。1.5 × 33,333 のような端数が整数の列に入らない
4 数量の入力欄 そもそも小数を打てない 1 文字打つたびに数値に直していたので、「1.」が 0 に戻る。スマホは小数点のキーも出ていなかった
5 写真の「壁紙を日替わりにする」 ON/OFF が毎回失敗する upsert(無ければ作る・あれば更新)の「作る」側が必須の列を満たしていなかった。PostgreSQL は、重なりを調べる前に必須の列を検査する

1・2 と 5 には共通点があります。エラーが握りつぶされて、画面では「0 件」や「何も起きない」に見えていたことです。supabase-js はエラーを例外にせず { error } として返すので、コードの側でログに出すだけにしていると、利用者には何も分かりません。

Drizzle は列の名前を型で検査するので、1 と 2 は書き換えた瞬間に型エラーとして見つかりました。移行のときは「出力を変えない」方針だったので、いったん元の振る舞いのまま写してコメントを残し、比較が一致したのを確かめてから別のコミットで直しています。直したあとに比較ページで差が出たのは、狙った所(庭に入金が数えられる、カレンダーに見積の期限が出る)だけでした。

3 と 4 は二段になっていました。サーバー側(3)を直して「これで小数が保存できる」と思ったのですが、プレビュー環境で試したらそもそも 1.5 が打てないことに私が気づきました。サーバーを直しても、画面からそこに辿り着けなかったわけです。数量の入力は、打ちかけの文字をそのまま持ち、読める数値になったものだけを渡す専用の部品に替えました。

なお、1 の「庭」と 2 のカレンダーは、その後あまり使われていないことが分かり、画面ごと削除しました。

つまずいた所

本番だけ「0 件」になった(IPv6 専用の接続口)

土台を本番に出す前、プレビュー環境で開いたら一覧が空でした。エラーの画面は出ず、ただ「0 件」です。ログ(vercel logs --environment preview --expand)を見ると getaddrinfo ENOTFOUND postgres と出ていました。

原因は 2 つ重なっていました。

  • Supabase の接続画面で「Use IPv4 connection」を OFF のままコピーした接続文字列は、IPv6 専用の接続口(Dedicated pooler)のもので、Vercel からはつながらない。ON にすると、IPv4 でつながる共用の接続口(Shared pooler)の文字列になる
  • DB のパスワードに記号が入っていて、接続文字列(URL)がそこで途切れていた。postgres というホストを探しに行ったのはこのため。パスワードを英数字だけにして解決

画面が「0 件」に見えたのは、このコードも失敗したら空の配列を返す作りだったからです。不具合 1・2 と同じ構図でした。Vercel の環境変数は Secret にすると値を見返せないので、症状はログで見るしかありません。

返ってくる値の形が違う

Drizzle が使う postgres.js は、数値(numeric)を文字列で、日時を 2026-10-04 12:11:23+00 の形で返します。supabase-js は数値は数値、日時は T 区切りで +00:00 の形でした。画面側は後者を前提にしているので、接続を作るところで形を揃えました。

const db = drizzle({ client, schema: fullSchema });
// drizzle は日付型の parser を「そのまま返す」に差し替えるので、そのあとで上書きする
client.options.parsers[1184] = isoTimestamp; // timestamptz

ここは順番に注意が要ります。Drizzle は drizzle() を呼んだときに日付の変換を自分の設定で差し替えるので、それより前に設定しても上書きされます。drizzle() のあとで設定します。

drizzle-kit pull が壊れた定義を出す

DB から定義を生成すると、ログインのユーザー表(auth.users)への外部キーが、定義されていない users を指す形で出てきました。生成の対象を public スキーマに絞っているためです。生成のたびに最小の定義を足す後処理のスクリプトを書き、マイグレーションを足したら毎回それを通すことにしました。

比べる仕組みのほうが揺れた

  • 同じ中身の行で比較が揺れる:自分と他人の同じ形の請求書や、複製した請求書は、ラベルの付け方が実行ごとに入れ替わることがありました。親のテーブルから順にラベルを付け、作られた順を並べる基準にして、5 回続けて安定しました
  • 排他のつもりのロックが効いていなかった:比較を同時に流すとテストデータが混ざらないよう PostgreSQL の advisory lock で排他にしていましたが、同じ接続を使い回すと同じ人として何度でも取れてしまい、排他になりませんでした。開発サーバーは 1 つなので、アプリ側で順番待ちの行列にして解決
  • コードを直した直後に流すと揺れる:開発サーバーの作り直しと重なるので、落ち着いてから流す

プレビュー環境の共有リンクが開けない

共有リンクを「ログインしていない人」として確かめようと、シークレットウィンドウで開いたら Vercel のログイン画面が出ました。Vercel のプレビュー環境には、関係者以外が見られないようにする保護がかかっているからです(本番には無い)。閲覧ページは常に未ログインとして DB を引く作りなので、普段のウィンドウで開いても取引先と同じ結果になります。

PostgREST の 1000 行の上限が無くなった

これはつまずきではなく気づきです。Supabase の PostgREST は、既定で 1 回の問い合わせで返す行を 1,000 件までに抑えています。直接つなぐとこの上限が無くなるので、年間の集計や CSV の書き出しは件数を絞らずに全部引くようになりました。1 年の記録が 1,000 件を超えると結果が変わりますが、正しくなる方向です。

試験のツボ

この回は AWS のサービスを直接使っていませんが、RDS につなぐアプリを設計するときに試験(Solutions Architect – Associate/SysOps Administrator – Associate)で問われる観点です。

  • RDS Proxy:アプリと RDS のあいだに置く、AWS 管理の接続プール。短い接続をたくさん作るアプリ(Lambda など)で DB の接続数があふれるのを防ぐ。今回使った Supabase の Transaction pooler と同じ役割
  • Secrets Manager:DB のパスワードなどを保管し、ローテーション(定期的な入れ替え)もできる。接続文字列をコードや環境変数の平文に書かない。RDS はマスターユーザーのパスワードを Secrets Manager に任せる設定がある(第4回)
  • IAM データベース認証:パスワードの代わりに、IAM で発行する 15 分有効のトークンで RDS にログインする方法。長期のパスワードを持たずに済む
  • 責任共有モデル:RDS では DB エンジンの更新やバックアップの仕組みは AWS の担当だが、「誰がどの行を読めるか」のようなデータへのアクセス制御は利用者の担当。RLS はその利用者側の仕組み
  • RDS と Aurora の PostgreSQL:どちらも PostgreSQL 互換なので、RLS やロールの切り替えといった PostgreSQL の機能はそのまま使える。アプリを PostgreSQL の標準の機能だけで書いておくと、移り先を選べる

まとめ

  • Supabase から離れるときの難所は DB の呼び出し方(PostgREST)。Drizzle は Supabase の PostgreSQL にもつなげるので、DB を移す前に、戻せる状態のまま本番で書き換えを終えた
  • withUser() がトランザクションの中でロールと request.jwt.claims を立てるので、67 本の RLS を 1 本も書き換えずに済んだ。素の接続は外に出さず、通し忘れを起こさない
  • 1 つの操作の書き込みは 1 つのトランザクションに。途中で失敗すれば採番も戻る。redirect() は外で呼ぶ
  • 元の実装を残して新旧を機械的に突き合わせた(読み取り 125 通り、書き込み 20 シナリオ・273 回の操作)。【他人】のデータを混ぜ、わざと壊した版で検出できることも先に確かめた
  • 型で列を検査するようになり、エラーの握りつぶしで隠れていた不具合が 5 件見つかった。DB のエラーは握りつぶさずログに出す

次回(第2回)からは、いよいよ AWS 側の作業です。アカウントを作ったあと最初にやった設定(root の MFA、予算アラート、IAM Identity Center での普段使いのユーザー、アクセスキーを作らずに使う CLI の SSO)を、つまずいた画面と一緒に書きます。

参考にした公式情報(2026 年 11 月時点):