家計簿アプリを作る #85:全データのバックアップ・リストア機能

家計簿アプリ作成シリーズの第85回です。これまでのCSVエクスポート/インポートは取引データのみが対象でしたが、今回はカテゴリ・予算・定期収支・テンプレートなどアプリの全データをJSON形式で一括バックアップ・リストアできる機能を実装しました。

ハマりポイント①:ヘッダー名のtypo

バックアップのダウンロードAPIを実装したものの、ファイルとしてダウンロードされず、ブラウザ上にJSONがそのまま表示されてしまう不具合がありました。

return new Response(JSON.stringify(backup, null, 2), {
  headers: {
    'Content-Type': 'application/json',
    'Content-Disposiotion': `attachment; filename="..."`, // ← typo
  }
});

Content-DispositionがContent-Disposiotionになっていました。ブラウザは知らないヘッダー名を単に無視するだけなのでエラーにはならず、「ダウンロード指定の無い普通のレスポンス」として扱われ、その場に表示されてしまっていました。

ハマりポイント②:GETフォームにuse:enhanceを付けてはいけない

ダウンロードボタンのフォームに、他のフォームと同じ感覚でuse:enhanceを付けていました。

<!-- ❌ -->
<form method="GET" action="/api/backup" use:enhance>
  <Button type="submit">ダウンロード</Button>
</form>

enhanceは本来「フォームアクション(?/xxxのようなSvelteKitのactionへのPOST)」を対象にした仕組みで、レスポンスとして特定の形式(action result)を期待します。/api/backupは通常のJSONを返すAPIエンドポイントであり?/actionではないため、enhanceが想定外のレスポンス形式を受け取ってエラーを起こし、それがページ全体のクライアント側のリアクティビティを壊してしまっていました。結果として、無関係なはずのタブのハイライトがずれたり、別のボタンが反応しなくなったりする不可解な症状につながっていました。

<!-- ✅ -->
<form method="GET" action="/api/backup">
  <Button type="submit">ダウンロード</Button>
</form>

GETでの純粋なファイルダウンロードは、ブラウザ標準のフォーム送信(通常のナビゲーション)に任せるのが正解でした。

ハマりポイント③:D1はdb.transaction()を使えない

リストア処理で「既存データを全削除してから復元」する際、最初はDrizzleのdb.transaction()を使いました。

// ❌ D1では動かない
await db.transaction(async (tx) => {
  await tx.delete(transactions)...;
  await tx.insert(transactions).values(t);
  // ...
});

実行するとError: Failed query: beginというエラーになりました。原因は、CloudflareのD1バインディングが生のBEGIN/COMMIT文をサポートしていないことでした。D1で複数のクエリをアトミックにまとめて実行するには、db.batch()を使う必要があります。

// ✅
const queries = [
  db.delete(transactions).where(eq(transactions.userId, locals.user!.id)),
  db.delete(categories).where(eq(categories.userId, locals.user!.id)),
  // ...他のdelete
  ...(backup.transactions ?? []).map((t) => db.insert(transactions).values(t)),
  ...(backup.categories ?? []).map((c) => db.insert(categories).values(c)),
  // ...他のinsert
];

await db.batch(queries as [typeof queries[number], ...typeof queries]);

ポイントは、可変長のinsertクエリをfor文で後からpushするのではなく、1つの配列リテラルの中でスプレッド構文を使ってまとめて組み立てることです。const queries = [...]で先に配列を確定させてからpushで異なる型(delete→insert)の要素を追加しようとすると、TypeScriptが最初の要素だけから配列の型を推論してしまい、型エラーになりました。最初から全種類の要素を1つの式の中に並べることで、TypeScriptがそれら全体を見てユニオン型として推論できるようになります。

ハマりポイント④:flowbite-svelteのTabsで複数タブがハイライトされる

タブを1つ追加したところ、あるタブをクリックすると別のタブも選択中の色になってしまう不具合が発生しました。flowbite-svelteのTabItemのソースを確認すると、各タブの識別にkey ?? tabId(tabIdはSvelteの$props.id()による自動生成ID)を使っており、keyを明示的に指定していないとタブの識別が不安定になるようでした。各TabItemに一意なkeyを指定することで改善を図りましたが、実際の根本原因は上記②のuse:enhanceの誤用によるページ全体のクラッシュだったため、そちらを直してようやく解消しました。

ハマりポイント⑤:リダイレクトループ

リストアのテスト中、設定画面から一覧に戻ろうとすると謎の500エラー(ブラウザのコンソールにはError: Redirect loop)が発生しました。

一覧ページのload処理には、「今月分の定期収支がまだ登録されていなければ自動登録し、同じURLにリダイレクトして再読み込みする」というロジックがあります。

if (unregisteredRecurringTransactions.length > 0) {
  for (const r of unregisteredRecurringTransactions) {
    await db.insert(transactions).values({ /* ... */ });
  }
  redirect(303, url.pathname + url.search); // 自分自身にリダイレクト
}

普段は1回のリダイレクトで収まりますが、リストアで一時的にデータの整合性が崩れた状態と重なったタイミングで、この「登録→リダイレクト」を繰り返してしまい、SvelteKit側のリダイレクトループ検出に引っかかっていたようでした。D1上のデータを直接クエリで確認したところ、その時点では既に整合性が取れており、再度操作したらエラーは再現しませんでした。恒久的なバグというよりは、リストア直後の一時的な状態が引き起こした事象だったようです。

まとめ

ポイント 内容
ヘッダー名のtypo Content-Dispositionのスペルミスはエラーにならず静かに無視されるため気付きにくい
GETフォームとuse:enhance enhanceはSvelteKitのform actionのためのもの。通常のAPIエンドポイントへのGETには付けない
D1のトランザクション db.transaction()は使えない。複数クエリのアトミックな実行にはdb.batch()を使う
配列の型推論 複数の型を含む配列は、pushで後から追加せず、最初から1つの式でまとめて作る
自己リダイレクトの罠 「処理→自分自身にリダイレクト」というパターンは、前提が崩れると簡単に無限ループ化する
← トップページに戻る