家計簿アプリを作る #81:月次サマリーメールの実装

家計簿アプリ作成シリーズの第81回です。前回はCloudflare Cron Triggersの基礎知識をまとめました。今回はSvelteKitプロジェクトにscheduledハンドラを組み込み、月次サマリーメールを実際に送信するまでの実装をまとめます。

アダプタがmainを勝手に上書きする問題

@sveltejs/adapter-cloudflare のソースコードを確認すると、ビルドのたびに以下を行っていました。

builder.rimraf(worker_dest);   // main が指すファイルを削除
builder.copy(`${files}/worker.js`, worker_dest, {...}); // 自前のfetchオンリーのテンプレートで上書き

worker_dest は wrangler.jsonc の main の値がそのまま使われます。つまり、アダプタは「main は自分がビルド成果物を書き込む場所」という前提で動作しており、main に独自のラッパーファイルを指定しても、次のビルドで丸ごと上書きされてしまう構造的な制約がありました。

postbuildスクリプトで「パッチを当てる」

この上書きを完全に避けるのは無理なので、「アダプタに一旦好きに書き込ませてから、直後に中身を入れ替える」という2段構えにしました。

// scripts/patch-worker.js
import fs from 'node:fs';

const generatedPath = 'src/worker-entry.js';
const savedPath = 'src/generated-entry.js';

// アダプタが書き込んだ内容(fetchオンリー)を退避
fs.copyFileSync(generatedPath, savedPath);

// scheduledを追加したラッパーで上書きする
const wrapper = `
import handler from './generated-entry.js';
import { sendMonthlySummaryEmails } from './lib/server/monthlySummaryMail.js';

export default {
  fetch: handler.fetch,
  async scheduled(event, env, ctx) {
    ctx.waitUntil(sendMonthlySummaryEmails(env));
  }
};
`;

fs.writeFileSync(generatedPath, wrapper);
// package.json
"scripts": {
  "build": "vite build && node scripts/patch-worker.js"
}

同じディレクトリ内に退避コピーするのがポイントです。アダプタが生成したコードは相対パスでimportしているため、別ディレクトリに移動すると壊れてしまいます。

$lib/serverに置く理由

ロジック本体(sendMonthlySummaryEmails)は $lib/utils ではなく $lib/server に置きました。SvelteKitには「$lib/server 配下のファイルはクライアント側のコードから絶対にimportできない」という仕組みがあり、DBアクセスやAPIキー(Resend)のような機密性の高い処理は $lib/server に置くことでこの保護を受けられます。逆に $lib/utils は、DBやAPIキーに触れない純粋な計算関数のためのディレクトリです。

Env型の生成とtsconfigの調整

worker-entry.js の scheduled ハンドラの引数に型を付ける際、Env 型が見つからないエラーが出ました。Env は wrangler.jsonc のbindingsに応じて内容が変わるプロジェクト固有の型のため、Wranglerのコマンドで生成する必要がありました。

npx wrangler types

生成された worker-configuration.d.ts がTypeScriptのプロジェクトに認識されるよう、tsconfig.json の include に明示的に追加しました。extends を使っている場合、子の include が継承元を上書き(マージではなく置き換え)してしまう点に注意が必要でした。

前月の年月をタイムゾーン安全に計算する

Date オブジェクトの getMonth() を直接操作すると、以前遭遇したのと同じ「サーバーの実行環境がUTCだと日付がずれる」問題を再び踏みかねません。既存の formatLocalDate(JST基準でYYYY-MMを返す)を使い、文字列化してから数値として1ヶ月分引く方法にしました。

function getPreviousMonth(): string {
  const currentMonthStr = formatLocalDate(new Date(), 'month');
  const [year, month] = currentMonthStr.split('-').map(Number);
  const prevMonth = month === 1 ? 12 : month - 1;
  const prevYear = month === 1 ? year - 1 : year;
  return `${prevYear}-${String(prevMonth).padStart(2, '0')}`;
}

全ユーザー分をまとめて集計する

「取引データだけで集計する」方式だと、前月に1件も取引が無かったユーザーが集計結果から漏れてしまいます(メール送信対象からも外れてしまう)。user テーブルを起点にすることで、この漏れを防ぎました。

// 1. 全ユーザーを取得(宛先確定のため)
const allUsers = await db.select({ id: user.id, email: user.email }).from(user);

// 2. 前月分の取引を全ユーザー分まとめて1回で取得
const prevMonthTransactions = await db.select().from(transactions)
  .where(like(transactions.date, `${prevMonth}%`));

// 3. ユーザーIDごとにグルーピングして集計(JS側)
const summaryByUserId = new Map<string, ReturnType<typeof calculateTotals>>();
for (const u of allUsers) {
  const userTx = prevMonthTransactions.filter(t => t.userId === u.id);
  summaryByUserId.set(u.id, calculateTotals(userTx));
}

DBへのクエリはユーザー数に関係なく一定回数で済み、取引が無いユーザーもループに含まれるので漏れなくサマリーを送れます。

ハマりポイント:デバッグログの置き場所

ビルド後の curl テストで応答が exception のまま変わらず、原因調査に苦労しました。最終的には、Wranglerのバージョンが古く(assetsとscheduledの組み合わせに関する不具合)、npm install -D wrangler@latest --legacy-peer-deps で最新版に上げることで解決しました。

途中、デバッグ用のログを worker-entry.js に直接書いていたことがありましたが、このファイルは patch-worker.js によってビルドのたびに機械的に上書きされるため、追加したログがビルドごとに消えてしまう罠がありました。デバッグ用のログは、ビルドで上書きされない monthlySummaryMail.ts 側に置くべきでした。

まとめ

ポイント 内容
アダプタのmain上書き問題 postbuildスクリプトで生成物を退避し、scheduledを追加したラッパーに差し替える
$lib/serverの使い分け DB・APIキーに触れる処理はクライアントに漏れない$lib/serverに置く
Env型の生成 npx wrangler typesで生成し、tsconfigのincludeに明示的に追加する
前月の計算 JST基準の文字列に変換してから数値で1引く。Dateのメソッドを直接操作しない
全ユーザーの集計 userテーブルを起点にし、取引0件のユーザーも漏らさない
ビルドで消えるファイル 自動生成・上書きされるファイルにデバッグログを書いても残らない
← トップページに戻る