家計簿アプリを作る #96:リストア処理の見直し

家計簿アプリ作成シリーズの第96回です。お題85で作ったバックアップ・リストア機能に、いくつか積み残しがありました。改めて読み直したところ、積み残し以外にデータが復元されないバグも見つかったので、まとめて対応します。

any型に隠れていたタイプミス

まずこれです。

...(backup.transacitons ?? []).map((t: typeof transactions.$inferInsert) =>
  db.insert(transactions).values(t)
),

backup.transacitonsが誤りで、正しくはbackup.transactionsでした。バックアップのJSONにそのキーは存在しないので?? []が働き、取引が1件も挿入されません。その手前で既存の取引は全削除しているので、リストアを実行すると取引データが消える、という状態でした。

型エラーにならなかったのは、JSON.parseの戻り値がany型だからです。anyのプロパティアクセスは何を書いても通るので、タイプミスがそのまま素通りします。TypeScriptを使っていても、外部から来たデータの境界では型の守りが無くなることを実感した例でした。

お題85の時点で動作確認をしたつもりでいたのですが、「エラーが出ずに完了した」ことを確認していただけで、データが戻っているかは見ていませんでした。今回は確認手順に件数の照合を入れました。

npx wrangler d1 execute kakeibo-app-db --local --command "SELECT (SELECT COUNT(*) FROM transactions) AS t, (SELECT COUNT(*) FROM categories) AS c, (SELECT COUNT(*) FROM transaction_templates) AS tt"

復元対象から漏れていたテーブル

バックアップのJSONにはtransactionTemplatesとbudgetAlertsが含まれているのに、リストア側では削除も挿入もしていませんでした。アカウント削除で同じ2つが漏れていたのと同じパターンです(お題87)。後から追加したテーブルは、こうして取りこぼされます。

R2の孤児ファイル:何を残して何を消すか

リストアは取引を全削除して入れ替えるので、R2のレシート画像が取り残されます。ただ、お題91の削除処理のように「消す取引の画像を消す」としてしまうと、復元される取引の画像まで消してしまいます。

整理するとこうなります。

  • リストア後にDBに存在するのは、バックアップファイルに含まれる取引だけ
  • したがって残すべきは、バックアップファイル内の取引が参照しているキー
  • 削除すべきは、現在のDBのキー − バックアップのキー
// 現在のDBにあるキー
const currentReceipts = await db
  .select({ key: transactions.receiptKey })
  .from(transactions)
  .where(and(eq(transactions.userId, locals.user!.id), ne(transactions.receiptKey, '')))
  .all();

// バックアップファイルに含まれるキー
const backupReceipts = new Set(
  (backup.transactions ?? [])
    .map((t: typeof transactions.$inferInsert) => t.receiptKey)
    .filter((key: string | undefined) => key)
);

// 差分が削除対象
const receiptsToDelete = currentReceipts
  .map((r) => r.key)
  .filter((key) => !backupReceipts.has(key));

照合にSetを使っているのは、includesで配列を毎回走査するより検索が速いからです。取引が数千件あると差が出ます。

削除の順番は、これまでと同じくR2が先です。R2の削除に失敗すればDBは手つかずでやり直せますし、逆順にするとDBを入れ替えた時点で「元のキー」が失われ、孤児ファイルを特定できなくなります。

なお、この方式でもバックアップを取った後に削除した取引の画像は戻りません。バックアップのJSONに入っているのはreceiptKeyという文字列だけで、画像の実体は含まれていないためです。「DBの状態は完全に復元、画像はR2に残っているものだけ」という割り切りにしました。

アップロードされたファイルをどこまで信用するか

ここが今回いちばん重要な論点でした。修正前は、JSONの内容をそのままDBへ挿入していました。

db.insert(transactions).values(t)   // t はファイルの中身そのまま

tにはuserIdも含まれています。バックアップファイルは利用者の手元にあるただのテキストなので、中身を書き換えるのは簡単です。userIdを他人のIDに変えたファイルをアップロードすれば、他人のアカウントにデータを挿入できてしまいます。

アップロードされたデータのうち信用してよいのは金額や日付といった値だけで、持ち主が誰かはサーバー側が決めるべきでした。挿入時に上書きします。

db.insert(transactions).values({ ...t, userId: locals.user!.id })

{ ...t, userId: ... }はtの内容を展開した後にuserIdを上書きするので、ファイル側の値が何であっても自分のIDになります。7テーブルすべてに同じ対応を入れました。

お題89で外部キー制約を入れたので、存在しないIDなら制約違反で弾かれます。しかし実在する他人のIDを指定された場合は通ってしまうので、制約だけでは防げません。DBの制約は「整合性」を守るもので、「権限」を守るものではない、という区別が要るところでした。

idについては、ファイルの値をそのまま使っています。自分のデータを自分に戻す通常の使い方では、直前に同じIDの行を削除しているので問題ありません。他人のファイルを入れた場合は主キーの重複でバッチ全体が失敗しますが、DBは元のままでuserIdの上書きも効いているので実害はなく、エラーの見え方が不親切なだけです。IDを振り直すと、取引のrecurringIdが定期収支のidを参照している対応関係も作り直す必要があり、手間の割に得るものが少ないので、「自分のバックアップを自分に戻す機能」と割り切りました。

入力の検証と、エラーの見せ方

2つ直しました。

チェックの順番

const text = await file.text();   // ← file が null ならここで例外
if (!file) { ... }                // ← ここに到達しない

検証がfile.text()の後ろにあり、機能していませんでした。

JSON.parseの失敗

バックアップ以外のファイルを選ぶと例外で500エラーになります。お題91では「try/catchは整合性のためには不要」と整理しましたが、今回は性質が違います。あれは「処理が途中で止まること自体が正しい動作」でしたが、こちらは利用者の操作ミスとして十分ありうる入力なので、エラー画面ではなく画面上のメッセージで知らせるべき場面です。

なお、最初はfail(400, { error: '...' })を返したものの、画面には何も出ませんでした。表示側が見ているのはform?.errors?.fileだけで、返り値のキーと噛み合っていなかったためです。ファイル選択欄のエラーとして扱う形に揃えました。

return fail(400, { errors: { file: 'バックアップファイルの形式が正しくありません' } });

ちなみに、拡張子を.jsonにしたCSVファイルで試しています。<Fileupload accept=".json">はファイル選択ダイアログで候補を絞るだけの指定で、中身は何も保証しません。クライアント側の指定はサーバー側の検証の代わりにならない、という当たり前のことを実地で確認した形です。

まとめ

ポイント 内容
anyの境界 JSON.parseの結果はany。プロパティ名のタイプミスは型チェックを素通りする
動作確認の質 「エラーが出なかった」ではなく「データが戻っている」ことを確認する
R2の差分削除 消すのは「現在のキー − バックアップのキー」。全部消すと復元対象まで消える
信用の境界 アップロードされた値のうち、持ち主が誰かはサーバーが決める
制約と権限 外部キー制約は整合性を守るが、権限は守らない
accept属性 ファイル選択の絞り込みでしかなく、サーバー側の検証の代わりにはならない
エラーの見せ方 返り値のキーと表示側のキーが噛み合っていないと、何も出ない
← トップページに戻る