家計簿アプリを作る #100:100回の振り返り
2026年5月31日に「SvelteKit × Cloudflare D1 × Drizzle の環境構築」から始めたこのシリーズが、100回に到達しました。約4ヶ月、99本の記事(うち38本がデプロイ回)を書いてきたことになります。最終回は、作ったものと学んだことの振り返りです。
作ったもの
家計簿アプリとしては、ひと通りのことができるところまで来ました。
- 収支管理 — 登録・編集・削除・一括削除、月別フィルタ、キーワード検索(全角/半角を区別せずハイライト表示)、カテゴリ絞り込み、カラムソート、ページネーション
- 予算管理 — カテゴリ別の予算と消化状況、月間の総予算と進捗、超過時のメール通知
- 定期収支 — 毎月決まった日の固定費・収入を自動登録
- クイック登録 — よく使う取引をテンプレート化して1クリックで登録
- レシート画像 — R2への添付・差し替え・削除
- 集計 — 年間サマリー、月次推移グラフ、カテゴリ別の月次推移グラフ
- データ入出力 — CSVエクスポート/インポート、全データのJSONバックアップ/リストア
- メール通知 — 予算超過アラート、Cron Triggerによる月次サマリー
- その他 — ダークモード、レスポンシブ、PWA、アクセシビリティ対応、アカウント削除
技術スタックは、SvelteKit(Svelte 5) / Cloudflare Workers・D1・R2 / Drizzle ORM / Better Auth / Resend / Tailwind CSS v4 + flowbite-svelte / Chart.js / Vitest + Playwright です。
進み方の変化
100回を並べてみると、3つの時期に分かれていました。
前半(#1〜#50頃)は、機能を足す時期でした。環境構築、CRUD、認証、デプロイ、UI、集計、グラフ、CSV入出力。「作りたいものを作る」が素直に進む時期です。
中盤(#51〜#85頃)は、実用に耐えるようにする時期でした。一括削除、月間予算、ダークモード対応のグラフ、ユニットテスト、アクセシビリティ、メール通知、Cron、レシート画像、バックアップ。ここから、外部サービスとの連携やCloudflare固有の事情に踏み込むようになります。
後半(#86〜#100)は、直す・固める時期でした。E2Eテストの導入、外部キー制約とカスケード削除、R2の孤児ファイル対策、レシート画像の所有者チェック、リストア処理の見直し。新機能はほとんど作っていません。
面白かったのは、後半の課題がほぼすべて前半・中盤に自分で作った負債だったことです。「テーブルを追加したのに削除処理に足し忘れる」「レシート画像を追加したのに削除処理を作っていない」「認証はレイアウトで守っているつもりだった」。機能を足すたびに、それと整合させるべき場所が増えていたのに、気付いていませんでした。
特にハマった5つ
1. アダプタがWorkerのエントリを上書きする(#80〜#82)
Cron Triggerで月次サマリーメールを送ろうとしたら、scheduled ハンドラを書いても毎回消えるという問題に当たりました。@sveltejs/adapter-cloudflare のソースを読んだところ、ビルドのたびに wrangler.jsonc の main が指すファイルを自前のテンプレートで上書きしていました。
結局、「アダプタに好きに書かせてから、直後に中身を入れ替える」後処理スクリプトを書いて解決しました。フレームワークが用意した枠の外に出たいとき、まずそのフレームワークのソースを読むというのが確実な解決への近道でした。
2. UTCで動くサーバー(#70ほか)
new Date().getMonth() のようなコードは、手元のMac(JST)では正しく動きますが、Cloudflare Workers(UTC)では日付がずれます。深夜に登録すると前日になる、という類のバグです。
Intl.DateTimeFormat に timeZone: 'Asia/Tokyo' を明示する形に統一しました。ローカルで動いたことは、本番で動く根拠にならないという、このシリーズで何度も出てくる教訓の最初の例でした。
3. D1はSQLiteだが、SQLiteそのものではない(#85, #89)
db.transaction()は使えない(BEGINが通らない)。複数クエリをまとめるならdb.batch()PRAGMA foreign_keysをクエリから変更できない。代わりにPRAGMA defer_foreign_keys = onUNIONで繋げられる項の数に上限がある
どれもドキュメントに書いてあることですが、「SQLiteだから同じように動くはず」という思い込みで書いて、エラーで気付くパターンでした。ローカル(Miniflare)は素のSQLiteとして振る舞うので、ローカルで通っても本番で落ちるものがあります。
4. 正規化しすぎる(#83)
検索で全角・半角を統一しようとして normalize('NFKC') を使ったところ、ハイライトが1文字ずれるバグが出ました。NFKCは半角カタカナと濁点を1文字に合成するため、正規化前後で文字数が変わり、インデックスがずれていたのです。
「全角英数字だけを半角にする」という、必要な範囲だけの変換に置き換えて解決しました。便利な標準関数ほど、やってくれることの範囲を確認する必要がありました。
5. ハイドレーション前の操作は巻き戻る(#95)
E2Eテストで、selectOption が成功しているのに選択が元に戻る、という現象に悩まされました。原因は、SSRされたHTMLに対してJavaScriptが結び付く前に操作していたことでした。bind:value の要素は、ハイドレーション時に状態の初期値で上書きされます。
しかもこれは、テストを並列実行して初めて顕在化しました。単体では通るのに全体で落ちるときは、負荷による遅延で顕在化する競合を疑う、という引き出しが増えました。
開発の仕方について学んだこと
「エラーが出なかった」は確認ではない
いちばん効いた教訓です。リストア機能で backup.transacitons というタイプミスがあり、取引が1件も復元されないのに、エラーも出ず正常終了していました(#96)。JSON.parse の戻り値は any なので、TypeScriptも何も言いません。
お題85の時点で動作確認をしたつもりでいましたが、見ていたのは「完了メッセージが出たこと」だけでした。以降は、SQLで件数を数える、R2の中身を確認する、といった手順を確認作業に入れるようにしました。
整合性は、注意力ではなく構造で守る
「テーブルを追加したら削除処理にも足す」を人間の注意力で守ろうとして、2回失敗しました。外部キー制約と ON DELETE CASCADE を入れてからは、そもそも足す必要がなくなりました(#89)。
一方で、R2のファイルはDBのカスケードでは消えないので、こちらは今も明示的な削除が必要です。「構造で守れるものは構造で守り、守れないものはドキュメントに残す」という切り分けで、READMEに注意書きを書きました。
認可はDBで判断する
R2はファイルの持ち主を知りません。そのため、レシート画像の削除でも表示でも、「そのキーが本当にこのユーザーのものか」の判断はD1側で行う必要がありました(#91, #93)。
同じ流れで、リストアではアップロードされたJSONの userId を信用せず、サーバー側で上書きするようにしました(#96)。外から来たデータのうち、どこまでを信用してよいかを意識するようになったのは、このシリーズの後半で得た一番大きな視点かもしれません。
Cloudflareのスタックで作ってみて
良かった点は、必要なものがひと通り揃っていて、どれも wrangler.jsonc に数行書けば繋がることです。DB(D1)、ファイルストレージ(R2)、定期実行(Cron Triggers)、静的アセット配信、デプロイ。インフラの構成をコードで管理でき、GitHubへpushすれば勝手にデプロイされる。個人開発で「作りたいもの」に集中しやすい環境だと感じました。
難しかった点は、ローカルと本番の差です。npm run dev ではバインディングが使えない、wrangler dev はビルド済みの成果物を見る、Cron Triggerは本番で手動実行できない、D1のPRAGMAはローカルでだけ効く。「ローカルでどこまで確認できて、何が確認できないか」を把握するまでに時間がかかりました。
逆に言えば、ここさえ押さえれば快適です。このシリーズで何度も書いた「ローカルで動いたことは本番の保証にならない」は、裏を返せばどこを確認すべきかが分かっていれば問題にならないということでもあります。
残した課題
区切りとしましたが、やり残したことはあります。
@cloudflare/workers-typesのv5への更新(--legacy-peer-depsで回避し続けている)- リストア処理のE2Eテスト(まさに見逃したバグを検出できるはずのテスト)
- アカウント削除時に、Better Authのテーブル以外の整合性をテストで守る仕組み
- 取引の複製、予算の前月コピーといった、細かな使い勝手の改善
おわりに
100回続けてみて思うのは、作る時間より、直す時間と確かめる時間の方が学びが多かったということです。機能を足しているときは順調ですが、そこで作った歪みは後から必ず出てきます。E2Eテストを書いていたらアプリのアクセシビリティの不具合が見つかった、というようなことも起きました(#95)。
ここまでお付き合いいただき、ありがとうございました。セットアップ手順や、このシリーズで踏んだ地雷のうち「知らないと壊してしまう箇所」は、READMEに整理してあります。