1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Supabaseを用いたバックアップ・リストア処理で遭遇したこと

1
Last updated at Posted at 2026-05-23

概要

上の記事の続きです。ユーザーの認証後、アプリにSupabaseを使ったデータのバックアップとリストアの機能を実装したときに遭遇したことを記事にしました。
今回の内容は以下のとおりです。いずれの内容も当たり前の事かも知れませんが、備忘のために残しておきます。参考になれば幸いです。

  • ローカル(アプリ側)のみのカラムをリモート(Supabase側)に送信すると PGRST204 エラーが発生する。根本解決はそのカラムが本当に必要かを見直すこと。
  • リモートスキーマに必要な user_id はバックアップ時に付与、リストア時に除去する必要がある

背景

今回のバックアップ・リストア機能の構成は下図になります、簡単な構成です。

ゴール

  • バックアップ(上書き):ローカルの全レコードを Supabase へ一括 upsert する
  • リストア:Supabase から全レコードを取得し、ローカルを完全置換する

【補足】ソースコードサンプル

Supabaseのクライアント
Supabaseクライアントは前回の記事と同様、以下を用いてDB操作を行います。
import { createClient } from '@supabase/supabase-js';
import * as SecureStore from 'expo-secure-store';
import Constants from 'expo-constants';

// Supabaseの認証情報
// ※環境変数で登録しているものとします
const supabaseUrl = process.env.SUPABASE_API_URL | undefined;
const supabaseAnonKey = process.env.SUPABASE_API_KEY as string | undefined;

const ExpoSecureStoreAdapter = {
  getItem:    (key: string) => SecureStore.getItemAsync(key),
  setItem:    (key: string, value: string) => SecureStore.setItemAsync(key, value),
  removeItem: (key: string) => SecureStore.deleteItemAsync(key),
};

/* 使用するSupabaseクライアント */
export const supabase = createClient(supabaseUrl ?? '', supabaseAnonKey ?? '', {
  auth: {
    storage: ExpoSecureStoreAdapter,
    autoRefreshToken: true,
    persistSession: true,
    detectSessionInUrl: false,
    flowType: "pkce", // 認証フロー
  },
});
SQLiteクライアント
※必要なテーブルやインデックスなどの定義はすでにされていものとします。
import * as SQLite from 'expo-sqlite';

/** シングルトンとして保持するDBインスタンス。未初期化時は null。 */
let sqlite: SQLite.SQLiteDatabase | null = null;

/**
 * SQLiteデータベースを開き、接続の最適化設定を適用する。
 *
 * - `PRAGMA foreign_keys = ON`: 外部キー制約を有効化する(SQLiteはデフォルトOFF)
 * - `PRAGMA journal_mode = WAL`: Write-Ahead Loggingで読み書きの並行性を向上させる
 */
export async function initDb(): Promise<SQLite.SQLiteDatabase> {
  // 既に初期化済みの場合は既存インスタンスをそのまま返す(冪等)。
  if (sqlite) return sqlite;
  
  sqlite = await SQLite.openDatabaseAsync('sqlite.db');
  await sqlite.execAsync('PRAGMA foreign_keys = ON;');
  await sqlite.execAsync('PRAGMA journal_mode = WAL;');
  return sqlite;
}

※簡略化のためにマイグレーションの処理は省いています。

バックアップ処理
// サイズの上限を設定
const BATCH_SIZE = 100;

// バックアップ・リストアの対象テーブル(FK 制約順:親 → 子)
const TABLES = [
  { local: 'items',          remote: 'items' },
  { local: 'child_records',  remote: 'child_records' },
  { local: 'detail_records', remote: 'detail_records' },
] as const;

/** ローカルの全レコードを Supabase へ一括アップロードする */
async function backupData(): Promise<void> {
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) throw new Error('ログインが必要です');

  for (const { local, remote } of TABLES) {
    const rows = await sqlite.getAllAsync<Record<string, unknown>>(
      `SELECT * FROM ${local}`,
    );
    for (let i = 0; i < rows.length; i += BATCH_SIZE) {
      const batch = rows
        .slice(i, i + BATCH_SIZE)
        .map((r) => toRemoteRow(r, user.id));
      const { error } = await supabase
        .from(remote)
        .upsert(batch, { onConflict: 'id' });
      if (error) throw error;
    }
  }
}

/** ローカル行に user_id を付与してリモート送信用に変換する */
function toRemoteRow(
  localRow: Record<string, unknown>,
  userId: string,
): Record<string, unknown> {
  return { ...localRow, user_id: userId };
}
リストア処理
// バックアップ・リストアの対象テーブル(FK 制約順:親 → 子)
const TABLES = [
  { local: 'items',          remote: 'items' },
  { local: 'child_records',  remote: 'child_records' },
  { local: 'detail_records', remote: 'detail_records' },
] as const;

// カスタムエラークラス
class NoCloudDataError extends Error {
  constructor() {
    super('クラウドにバックアップデータが見つかりませんでした');
    this.name = 'NoCloudDataError';
  }
}

/**
 * Supabase から全レコードを取得し、ローカルを完全置換する。
 * @throws {NoCloudDataError} クラウドにデータが存在しない場合
 */
async function restoreData(): Promise<void> {
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) throw new Error('ログインが必要です');

  const fetched: Record<string, Record<string, unknown>[]> = {};
  for (const { local, remote } of TABLES) {
    const { data, error } = await supabase
      .from(remote)
      .select('*')
      .eq('user_id', user.id);
    if (error) throw error;
    fetched[local] = (data ?? []) as Record<string, unknown>[];
  }

  if (fetched['items'].length === 0) throw new NoCloudDataError();

  // 現在存在するローカルデータの削除
  // ※ ON DELETE CASCADE が子・孫テーブルを連鎖削除しているものとします
  await sqlite.runAsync('DELETE FROM items');

  // FK 制約順(親 → 子)に挿入
  for (const { local } of TABLES) {
    for (const row of fetched[local]) {
      const { user_id: _uid, ...localRow } = row;
      const cols = Object.keys(localRow).join(', ');
      const placeholders = Object.keys(localRow).map(() => '?').join(', ');
      await sqlite.runAsync(
        `INSERT INTO ${local} (${cols}) VALUES (${placeholders})`,
        Object.values(localRow) as (string | number | null)[],
      );
    }
  }
}

【内容1】ローカル管理専用カラムを送信すると PGRST204 エラーになる

ローカルのDB(本記事の場合はSQLite)とSupabase(PostgreSQL)のテーブル間のカラムが揃っていない場合、SELECT * FROM テーブル でローカルの全カラムを取得してそのまま Supabase に upsert すると、次のエラーが返ってきます。

// ❌ ローカルの全カラムをそのまま upsert
const rows = await sqlite.getAllAsync('SELECT * FROM items');
const { error } = await supabase.from('items').upsert(rows);
// → PGRST204
# エラー
Could not find the '[column name]' column of 'items' in the schema cache (PGRST204)

Supabase の PGRST204 は「リクエストボディにスキーマキャッシュ上に存在しないカラムが含まれている」ことを意味します。開発の過程でローカルに SQLite にマイグレーションの処理を追加していくと、ローカルにしか存在しないカラムが生まれてしまい、上記のようなエラーが発生します。例えば、以下の表などを考えます。

カラム ローカル SQLite リモート Supabase 用途
id ✅ ✅ 主キー
local_flag ✅ ❌ ローカル専用の状態管理用
cache_key ✅ ❌ ローカルキャッシュの参照キー
user_id ❌ ✅ RLS によるユーザー分離用

SELECT * だと上表の local_flag や cache_key のようなローカル専用カラムも含まれてしまい、Supabase は PGRST204 を返します。エラーメッセージにカラム名が明記されているため、発生後は原因を特定しやすいが、SELECT * を使っている限り追加マイグレーションのたびに同じ問題が再発する可能性があります。

【内容2】ユーザーごとにデータを分けないとリストアできない

本当に初歩的なことですが、バックアップ・リストアデータはユーザーごとに分けて保存する必要があり、具体的にはメールアドレスやユーザーID、などユーザーを一意に決めるデータが必要になります。もしユーザーを判別するための識別子がない場合、WHERE句でユーザーデータの絞り込みができず、リストアができません。
今回はなかったのですが、学生時代のプログラミングを初めて間もない頃、このようなことに陥ったので自戒も込めて書いておきます。

  • バックアップ時:ローカル行に「ユーザーID」を付与してリモートに送る
  • リストア時:リモート行から「ユーザーID」を除去してローカルに挿入する

どうやって解決したのか

【内容1の解決】ローカル管理専用カラムを削除する

PGRST204 の根本原因は「ローカルにしか存在しないカラム」そのものなので、そのカラムが本当に必要かを考え直し、不要であれば削除すること(DROP COLUMN [column name])が最もシンプルな解決策になります。

-- ✅ 不要なカラムをマイグレーションで削除
ALTER TABLE items DROP COLUMN local_flag;
ALTER TABLE items DROP COLUMN cache_key;

削除後の toRemoteRow() は SELECT * の結果をそのまま展開し user_id を付与するだけになり、フィルタリングロジックが不要になりました。

function toRemoteRow(
  localRow: Record<string, unknown>,
  userId: string,
): Record<string, unknown> {
  return { ...localRow, user_id: userId };
}

バックアップ時はすべての行をこの関数に通してから upsert します(上書きします)。

const rows = await sqlite.getAllAsync<Record<string, unknown>>('SELECT * FROM items');

for (let i = 0; i < rows.length; i += BATCH_SIZE) {
  const batch = rows.slice(i, i + BATCH_SIZE).map((r) => toRemoteRow(r, userId));
  const { error } = await supabase.from('items').upsert(batch, { onConflict: 'id' });
  if (error) throw error;
}

【コラム】カラムの削除ができない場合のフィルタリング

ローカル専用カラムが何らかの理由で削除できない場合は、送信前にフィルタリングする方法もあります。具体的には、カラム名を Set で管理し、toRemoteRow() 内で除外します。

// バックアップ時にSupabase(リモート)に送らないカラムセット
const LOCAL_ONLY_COLUMNS = new Set(['local_flag', 'cache_key']);

function toRemoteRow(
  localRow: Record<string, unknown>,
  userId: string,
): Record<string, unknown> {
  const result: Record<string, unknown> = { user_id: userId };
  for (const [key, value] of Object.entries(localRow)) {
    // フィルタリング
    if (!LOCAL_ONLY_COLUMNS.has(key)) result[key] = value;
  }
  return result;
}

Set の has() は O(1) で動作するため、カラム数が増えても処理コストは変わりません。ただし、マイグレーションのたびに LOCAL_ONLY_COLUMNS の確認・更新が必要になるため、可能であれば不要カラムの削除を優先した方が良いです。


【内容2の解決】リストア時に user_id を除去する

自然な対応だとは思いますが、リモートから取得した行は分割代入で user_id を取り除いてからローカルに INSERT します。
※user_id: _uid とアンダースコアプレフィックスを付けることで、TypeScript の「未使用変数」警告を抑制しています。

for (const row of remoteRows) {
  // user_id はローカルスキーマに存在しないため除外
  const { user_id: _uid, ...localRow } = row;

  const cols = Object.keys(localRow).join(', ');
  const placeholders = Object.keys(localRow).map(() => '?').join(', ');

  await sqlite.runAsync(
    `INSERT INTO items (${cols}) VALUES (${placeholders})`,
    Object.values(localRow) as (string | number | null)[],
  );
}

【コラム】「バックアップなし」と「通信エラー」をカスタムエラークラスで区別する

リストア時にクラウドのデータが 0 件だった場合、「一度もバックアップしていない(正常)」と「通信エラーで取得できなかった(異常)」の2パターンがあり、これらを区別するために専用クラス(NoCloudDataError)を定義し、シンプルな分岐ができるように実装にしています。

// restoreData関数の呼び出し元
try {
  await restoreData();
} catch (e) {
  if (e instanceof NoCloudDataError) {
    console.log(e.message); // → クラウドにバックアップデータが見つかりませんでした
  } else {
    console.log('リストアに失敗しました'); // 通信エラーなど
  }
}

感想

今回、いろいろな話を省いているので気になることは多々あるかも知れません。例えば、以下のことが挙げられます。

  • 内容1について、なぜローカルとリモートでカラムが違うのか?
  • なぜ同じテーブルにユーザーのバックアップをまとめて挿入しているのか?
  • バックアップ処理時にバッチサイズの上限を設定しているのはなぜか?

上記のような開発のストーリーはまた別で記事にしたいとは思っています。
個人開発なので意思決定から開発作業までスムーズに運べますが、複数人で開発をする場合はこのスピード感はなかなか難しいかも知れないと思っています。上記の内容も一人で考え、選び、実装しました。なので個人開発で得られる学びは会社の業務とはまた違った経験になり、とても勉強になります。

以上になります。
最後までお読みいただきありがとうございました。

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?