Zenn

個人開発のDBをFirebaseからSupabaseに移行した話

2025/01/29に公開
55

こんにちは。はじめまして。れとるときゃりー(@retoruto_carry)と申します。

最近、個人開発しているサービスのDBをFirebase FirestoreからSupabaseに移行しました。
https://piku.page/

移行には2週間程かかりましたが、Firestoreでつらみを感じていてた部分が解消されて満足しています。

FirestoreはNoSQLなので、設計が難しく、画面のUI構成やユースケースを熟考したうえでデータ設計を考える必要があり、ガンガン仕様変更したり、複雑なクエリをしたりするには向いていない傾向があると感じていました。

Supabaseは、Firebaseの便利な部分を受け継ぎつつ、バックエンドがRDB(PostgreSQL)なので、上記の欠点が解消されています。

また、Firebaseがクエリごと料金が掛かるのと比較して、Supabaseはインスタンスごとの課金であり、セルフホストも可能のため、サービスがバズったときのコストの予測がつきやすい点も良いです。

(ただし、その反面、サーバーレスとは違いインスタンスを占有するため、Freeプランを除いてミニマムでもランニングコストが多少かかってしまう点はネガティブポイントです。Freeプランは2インスタンスまで使えます。)

ネット上に情報が少なかったので、移行の際に詰まったポイントなどについて、ざっくり記載しておこうと思います

公式のマイグレーションガイドを参考に進める

まずは、Supabase が提供している公式のマイグレーションガイドを参考に移行を進めましょう。
Firestore から Supabase への移行手順について、基本的な流れが解説されています。

📌 公式のマイグレーションガイド
https://supabase.com/docs/guides/platform/migrating-to-supabase/firestore-data

このガイドをベースに進めましょう。
ガイドの中で、移行用のツールが公式から提供されています。

https://github.com/supabase-community/firebase-to-supabase

ただし、いくつか追加で考慮すべきポイントがあるので、それらについて補足します。

Firebase Auth から Supabase Auth への移行

Firestore で Firebase Authentication を使っている場合、Supabase の Auth に移行する必要があります。

(一応Firebase AuthをサードパーティーAuthとして利用することもできるらしいが、今回は不使用)

ただし、パスワードはそのまま移行できず、リセットが必要になる点に注意が必要です。
最初のログイン時にパスワードリセットを促す必要があります。

ネストしたコレクションの移行

ここが一番大変だったポイントです。

Firestore では、ドキュメントの中にさらにサブコレクションを持つことができますが、Supabase (PostgreSQL) ではそのままでは扱えません。

そのため、サブコレクションを別テーブルとして管理する必要があります。

公式が提供する移行ツールでは、FirestoreのデータをJSONとしてエクスポートできます。

https://github.com/supabase-community/firebase-to-supabase/tree/main/firestore

ただし、そのままのコードでは、Firestoreのネストしたコレクションを取得できません。
そのため、ツールのコードを改造しましょう。

ドキュメントを取得した後に、それらの持つコレクション以下のドキュメントを取得し、さらにそれらの持つコレクション以下のドキュメントを取得し...といったように、ループを回してネストしたコレクションをすべて取得しましょう。

参考コード
users_processDocument.js
module.exports = async (
  collectionName,
  doc,
  recordCounters,
  writeRecord,
  db
) => {
  // === User_Profiles のデータを生成 ===
  const userProfile = {
    username: doc.username,
  };

  writeRecord("user_profiles", userProfile, recordCounters);

  // === Pages サブコレクションのデータを取得 ===
  console.log(`${collectionName}/${doc.username}/pages`);
  const pagesRef = db.collection(`${collectionName}/${doc.username}/pages`); // サブコレクション参照
  const pagesSnapshot = await pagesRef.get();

  console.log(`Pages found for user ${doc.username}:`, pagesSnapshot.size);

  for (const pageDoc of pagesSnapshot.docs) {
    const pageData = pageDoc.data();
    const pageRecord = {
      username: doc.username,
      firebase_uid: doc.uid,
      updated_at: pageData.updatedAt
        ? new Date(pageData.updatedAt.toDate()).toISOString()
        : new Date().toISOString(),
      created_at: pageData.updatedAt
        ? new Date(pageData.createdAt.toDate()).toISOString()
        : new Date().toISOString(),
    };

    writeRecord("pages", pageRecord, recordCounters);
  }

  return doc;
};

firestore2json.js(適当に改造)
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
    return new (P || (P = Promise))(function (resolve, reject) {
        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
        step((generator = generator.apply(thisArg, _arguments || [])).next());
    });
};
var __generator = (this && this.__generator) || function (thisArg, body) {
    var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
    return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
    function verb(n) { return function (v) { return step([n, v]); }; }
    function step(op) {
        if (f) throw new TypeError("Generator is already executing.");
        while (_) try {
            if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
            if (y = 0, t) op = [op[0] & 2, t.value];
            switch (op[0]) {
                case 0: case 1: t = op; break;
                case 4: _.label++; return { value: op[1], done: false };
                case 5: _.label++; y = op[1]; op = [0]; continue;
                case 7: op = _.ops.pop(); _.trys.pop(); continue;
                default:
                    if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
                    if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
                    if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
                    if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
                    if (t[2]) _.ops.pop();
                    _.trys.pop(); continue;
            }
            op = body.call(thisArg, _);
        } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
        if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
    }
};
exports.__esModule = true;
var utils_1 = require("./utils");
var fs = require("fs");
var args = process.argv.slice(2);
var processDocument;
if (fs.existsSync("./".concat(args[0], "_processDocument.js"))) {
    // read file to string
    processDocument = require("./".concat(args[0], "_processDocument.js"));
    // processDocument = fs.readFileSync(`./${args[0]}_processDocument.js`, 'utf8');
}
var db;
var recordCounters = {};
var limit = 0;
if (args.length < 1) {
    console.log('Usage: firestore2json.ts <collectionName> [<batchSize>] [<limit>]');
    process.exit(1);
}
else {
    db = (0, utils_1.getFirestoreInstance)();
    main(args[0], args[1] || '1000', args[2] || '0');
}
function main(collectionName, batchSize, limit) {
    return __awaiter(this, void 0, void 0, function () {
        return __generator(this, function (_a) {
            switch (_a.label) {
                case 0: return [4 /*yield*/, getAll(collectionName, null, parseInt(batchSize), parseInt(limit))];
                case 1:
                    _a.sent();
                    return [2 /*return*/];
            }
        });
    });
}
function getAll(collectionName, lastDoc, batchSize, limit) {
    return __awaiter(this, void 0, void 0, function () {
        var _a, data, error, newLastDoc;
        return __generator(this, function (_b) {
            switch (_b.label) {
                case 0: return [4 /*yield*/, getBatch(collectionName, lastDoc, batchSize, limit)];
                case 1:
                    _a = _b.sent(), data = _a.data, error = _a.error, newLastDoc = _a.lastDoc;
                    if (error) {
                        console.error("バッチ取得エラー:", error);
                        return [2 /*return*/];
                    }
                    if (!(data.length > 0)) return [3 /*break*/, 3];
                    console.log(`Retrieved ${data.length} records from ${collectionName}`);
                    return [4 /*yield*/, getAll(collectionName, newLastDoc, batchSize, limit)];
                case 2:
                    _b.sent();
                    return [3 /*break*/, 4];
                case 3:
                    (0, utils_1.cleanUp)(recordCounters);
                    console.log("All data has been retrieved");
                    _b.label = 4;
                case 4: return [2 /*return*/];
            }
        });
    });
}
function getBatch(collectionName, lastDoc, batchSize, limit) {
    return __awaiter(this, void 0, void 0, function () {
        var data, error, query;
        return __generator(this, function (_a) {
            switch (_a.label) {
                case 0:
                    data = [];
                    error = null;
                    console.log("=== Batch Fetch Start ===");
                    console.log("Collection:", collectionName);
                    console.log("Current Count:", recordCounters[collectionName] || 0);
                    console.log("Batch Size:", batchSize);
                    console.log("Limit:", limit);
                    console.log("Last Doc ID:", lastDoc ? lastDoc.id : "None");

                    if (recordCounters[collectionName] >= limit && limit > 0) {
                        console.log("Limit reached. Stopping fetch.");
                        return [2 /*return*/, { data: data, error: error, lastDoc: null }];
                    }
                    if (typeof recordCounters[collectionName] === 'undefined') {
                        recordCounters[collectionName] = 0;
                    }
                    if (limit > 0) {
                        batchSize = Math.min(batchSize, limit - recordCounters[collectionName]);
                    }
                    query = db.collection(collectionName).limit(batchSize);
                    if (lastDoc) {
                        query = query.startAfter(lastDoc);
                    }
                    return [4 /*yield*/, query
                            .get()
                            .then(function (snapshot) {
                                console.log("Fetched documents count:", snapshot.size);
                                var processPromises = [];
                                snapshot.forEach(function (fsdoc) {
                                    var doc = fsdoc.data();
                                    if (!doc.firestore_id)
                                        doc.firestore_id = fsdoc.id;
                                    else if (!doc.firestoreid)
                                        doc.firestoreid = fsdoc.id;
                                    else if (!doc.original_id)
                                        doc.original_id = fsdoc.id;
                                    else if (!doc.originalid)
                                        doc.originalid = fsdoc.id;
                                    if (processDocument) {
                                        processPromises.push(Promise.resolve(processDocument(collectionName, doc, recordCounters, utils_1.writeRecord, db)));
                                    } else {
                                        (0, utils_1.writeRecord)(collectionName, doc, recordCounters);
                                        data.push(doc);
                                    }
                                });
                                return Promise.all(processPromises).then(function(processedDocs) {
                                    processedDocs.forEach(function(doc) {
                                        if (doc) {
                                            data.push(doc);
                                        }
                                    });
                                    console.log("Processed documents count:", data.length);
                                    return {
                                        data: data,
                                        error: null,
                                        lastDoc: snapshot.docs[snapshot.docs.length - 1]
                                    };
                                });
                            })["catch"](function (err) {
                                console.error("Firestore query error:", err);
                                error = err;
                                return { data: [], error: err, lastDoc: null };
                            })];
                case 1:
                    return [2 /*return*/, _a.sent()];
            }
        });
    });
}

Firestore のセキュリティルールをSupabase RLSに移行する

Firestore では セキュリティルール を使ってデータアクセスを制御していましたが、Supabase では Row Level Security (RLS) に置き換える必要があります。

ただ、個人的には、FirestoreのセキュリティルールよりRLSのほうが使いやすくて良いな、と感じました。

サービスを停止せず移行する

この記事を参考に、段階的にFirestoreとSupabaseを入れ替えることで、安全に移行することができそうです。

(私はメンテナンス時間を取って一気に移行したので試していません)

https://emergence-engineering.com/blog/firestore-supabase-migration

(おまけ)Stripeの移行

Firebaseを使っていたときは、Stripeのサブスクリプション情報はRun Payments with StripeというFirebase Extensions
を使って実装していました。

Supabaseを使って実装するには、それらのExtensionsが内部的に実装しているcloud functionの機能を、自前で実装する必要があります。

具体的には

  • subscriptions
  • costomers
  • products

のテーブルをsupabaseに作成し、これらに対して、stripeのwebhooks経由でデータを同期して流し込むようにします。

ありがたいことに、vercelが公式でそのようなコードのサンプルを公開してくれているので、参考に実装しましょう。

vercel/nextjs-subscription-payments: Clone, deploy, and fully customize a SaaS subscription application with Next.js.

また、既に存在する顧客データなどを移行する必要する必要があります。
これらはStripeのAPIを通じて移行しました。

また、これらはFirebaseのユーザーに紐づいているので、Supabaseのユーザーに紐づけ直すように、いい感じに移行しましょう。

Stripeのデータを移行するコード
stripe-migration.ts
import path from 'path'
import { createClient } from '@supabase/supabase-js'
import { config } from 'dotenv'
import Stripe from 'stripe'
import { Database } from '../src/types/supabase'

config({
  path: path.resolve(__dirname, '.env'),
})

if (!process.env.STRIPE_SECRET_KEY) {
  throw new Error('STRIPE_SECRET_KEY is not defined')
}

if (!process.env.SUPABASE_URL || !process.env.SUPABASE_SERVICE_ROLE_KEY) {
  throw new Error('Supabase credentials are not defined')
}

// Stripeクライアントの初期化
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
  apiVersion: '2024-12-18.acacia',
  appInfo: {
    name: 'Stripe Migration',
    version: '1.0.0',
  },
})

const supabase = createClient<Database>(
  process.env.SUPABASE_URL,
  process.env.SUPABASE_SERVICE_ROLE_KEY,
)

async function migrateStripeCustomer(customer: Stripe.Customer) {
  console.log('migrateStripeCustomer', customer.id)
  if (customer.deleted) {
    console.log('customer.deleted', customer.id)
    return
  }

  // Stripeのmetadataに入ってるFirebaseUIDからSupabaseで対応するユーザーを検索

  const firebaseUid = customer.metadata?.firebaseUID
  if (!firebaseUid) {
    console.warn(`Customer ${customer.id} has no Firebase UID, skipping...`)
    return
  }

  console.log('firebaseUid->', firebaseUid)

  const { data: userData, error: userError } = await supabase
    .rpc('get_user_by_firebase_uid', { firebase_uid: firebaseUid })
    .single()

  if (userError || !userData) {
    console.error(`No matching Supabase user found for Firebase UID ${firebaseUid}`)
    return
  }

  // customersテーブルにデータを挿入
  const { error: customerError } = await supabase.from('stripe_customers').upsert({
    id: customer.id,
    user_id: userData.id,
  })

  if (customerError) {
    console.error('Error inserting customer:', customerError)
    return
  }

  // stripeのcustomerのmetadataにsupabaseUserIdを追加
  await stripe.customers.update(customer.id, {
    metadata: {
      ...customer.metadata,
      supabaseUserId: userData.id,
    },
  })

  console.log(`Migrated customer ${customer.id} for user ${userData.id}`)
}

async function runMigration() {
  try {
    console.log('Starting migration...')

    console.log('Migrating customers...')
    // auto-pagingを使用して全ての顧客を取得
    for await (const customer of stripe.customers.list({ limit: 100 })) {
      await migrateStripeCustomer(customer)
    }

    console.log('Migrating products and prices...')
    // 商品は数が少ないため、ページネーションは不要
    const products = await stripe.products.list({ limit: 100 })
    for (const product of products.data) {
      const { error: productError } = await supabase.from('stripe_products').upsert({
        id: product.id,
        active: product.active,
        name: product.name,
        description: product.description ?? null,
        image: product.images?.[0] ?? null,
        metadata: product.metadata ?? null,
      })

      if (productError) {
        console.error(`Error migrating product ${product.id}:`, productError)
        continue
      }

      // 商品に関連する価格を取得して移行
      for await (const price of stripe.prices.list({
        product: product.id,
        limit: 100,
        expand: ['data.product'],
      })) {
        const { error: priceError } = await supabase.from('stripe_prices').upsert({
          id: price.id,
          product_id: product.id,
          active: price.active,
          description: price.nickname ?? null,
          unit_amount: price.unit_amount ?? 0,
          currency: price.currency,
          type: price.type,
          interval: price.recurring?.interval ?? null,
          interval_count: price.recurring?.interval_count ?? null,
          trial_period_days: price.recurring?.trial_period_days ?? null,
          metadata: price.metadata ?? null,
        })

        if (priceError) {
          console.error(`Error migrating price ${price.id}:`, priceError)
        }
      }
    }

    console.log('Migrating subscriptions...')

    for await (const subscription of stripe.subscriptions.list({
      limit: 100,
      status: 'all',
      expand: ['data.customer', 'data.items.data.price'],
    })) {
      await migrateSubscription(subscription)
    }

    console.log('Migration completed successfully!')
  } catch (error) {
    console.error('Migration failed:', error)
    process.exit(1)
  }
}

// サブスクリプションの移行
async function migrateSubscription(subscription: Stripe.Subscription) {
  const customer = subscription.customer as Stripe.Customer
  console.log(`💳Subscription: id: ${subscription.id}, customerId: ${customer.id}`)

  // 対応するSupabaseユーザーを検索
  const { data: customerData } = await supabase
    .from('stripe_customers')
    .select('user_id, id')
    .eq('id', customer.id)
    .single()

  if (!customerData) {
    console.warn(
      `No matching customer found for subscription ${subscription.id}, firebaseUID-> ${customer.metadata?.firebaseUID}, customerId-> ${customer.id}`,
    )
    return
  }

  const subscriptionData: Database['public']['Tables']['stripe_subscriptions']['Insert'] =
    {
      id: subscription.id,
      user_id: customerData.user_id,
      customer_id: customerData.id,
      status: subscription.status,
      metadata: subscription.metadata ?? null,
      price_id: subscription.items.data[0].price.id,
      quantity: subscription.items.data[0].quantity ?? null,
      cancel_at_period_end: subscription.cancel_at_period_end,
      current_period_start: new Date(
        subscription.current_period_start * 1000,
      ).toISOString(),
      current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
      created: new Date(subscription.created * 1000).toISOString(),
      ended_at: subscription.ended_at
        ? new Date(subscription.ended_at * 1000).toISOString()
        : null,
      cancel_at: subscription.cancel_at
        ? new Date(subscription.cancel_at * 1000).toISOString()
        : null,
      canceled_at: subscription.canceled_at
        ? new Date(subscription.canceled_at * 1000).toISOString()
        : null,
      trial_start: subscription.trial_start
        ? new Date(subscription.trial_start * 1000).toISOString()
        : null,
      trial_end: subscription.trial_end
        ? new Date(subscription.trial_end * 1000).toISOString()
        : null,
    }

  const { error: subscriptionError } = await supabase
    .from('stripe_subscriptions')
    .upsert(subscriptionData)

  if (subscriptionError) {
    console.error(`Error migrating subscription ${subscription.id}:`, subscriptionError)
  }
  console.log(
    'migrateSubscription success: firebaseUID->',
    customer.metadata?.firebaseUID,
    'customerId->',
    customerData.id,
  )
}

runMigration()

DBを移行した感想

DBをまるっと移行したのですが、外部との通信をリポジトリ層として分離していたので、移行にあたりロジックを変更したりはなく、すんなり移行することができて良かったです。

設計はこういうときに生きるだな〜と思いました。

最後に

いいねや、ツイートで感想をシェアをしていただけると大変嬉しいです。

良かったら、私のツイッター(@retoruto_carry)もフォローしてね。

引き続き個人開発頑張っていきます!

↓応援よろしくお願いします🙏

55

Discussion

ログインするとコメントできます