Skip to content

Add spaced-repetition study mode on Cloudflare Pages, D1 and Access - #79

Merged
hiramekun merged 4 commits into
mainfrom
feat/srs-cloudflare
Aug 29, 2026
Merged

Add spaced-repetition study mode on Cloudflare Pages, D1 and Access#79
hiramekun merged 4 commits into
mainfrom
feat/srs-cloudflare

Conversation

@hiramekun

Copy link
Copy Markdown
Owner

スワイプするだけだったカードデッキを、Anki のような間隔反復(SRS)で出題する暗記アプリにする。ホスティングを GitHub Pages から Cloudflare Pages へ移し、学習状態を D1 に、認証を Cloudflare Access に置く。ランニングコストは月額 $0(すべて無料枠内)。

何が変わるか

/study はタイトルだけを見て中身を思い出し、答え合わせをしてから左右にスワイプする画面。

  • 右スワイプ = 覚えている(FSRS の Good)。安定度が伸び、次の出題まで間隔が開く
  • 左スワイプ = 覚えていない(FSRS の Again)。安定度が縮み、当日中にもう一度出る

スケジューラは Anki 23.10 以降の既定アルゴリズムである FSRS-6(ts-fsrs)。カード閲覧のページは公開のまま残し、/study/api だけを Access で自分ひとりに絞っている。

コミット

内容
e6f70be Terraform で D1 / Access / Pages を定義。ホスティングを Cloudflare Pages へ
3dd0549 D1 のスキーマ、Pages Functions の学習 API、ノート同期スクリプト
db80fc6 暗記モードの UI とオフライン用アウトボックス

1 つ目は単体では完結していない。sync-d1.yml が参照するマイグレーションと同期スクリプトは 2 つ目で入る。逆順にすると pages.yml を消した時点で他のワークフローが存在しない workflow を dispatch する状態になるため、こちらの壊れ方を選んだ。

設計上の勘所

  • review_logs を原本、card_states を派生として扱う。オフライン同期の順序ずれ・Undo・FSRS パラメータの再最適化が、すべて rebuildCardState() ひとつに帰着する
  • 冪等性(user_id, client_event_id) の一意索引。すり抜けた重複は D1 の batch ごと失敗させ、状態が二重に進まないようにしている
  • タイムゾーンとロールオーバー(午前 4 時)の計算はアプリ側で行い、study_day として保存する。SQLite にタイムゾーン付きの時刻型がないためだが、結果として日次集計が索引一発になり、無料プランの CPU 10ms にも優しい
  • Access の AUD タグは Terraform が Pages の環境変数へ直接注入する。read-only 属性なので参照できる。ダッシュボードから手で控えて貼る作業と転記ミスがなくなる
  • D1 のテーブルは Terraform に持たせない。スキーマ変更の履歴はマイグレーションが持つべきで、宣言的な差分検出とは相性が悪い

動作確認

wrangler pages dev + ローカル D1 で通した。

  • 既存ノート 47 件が notes / cards に同期
  • Good で採点 → newlearning、安定度 2.307、次回出題は 10 分後
  • 同じ clientEventId を再送 → applied: 0, skipped: 1(冪等)
  • Undo → カードが new に戻り、review_logs も消える
  • UI も通した。答えを見る → 本文表示 → 採点 → 次のカードへ

lint / typecheck(アプリと Functions 両方)/ テスト 23 件 / next build すべて通っている。

マージ前に必要な手作業

このブランチだけでは動かない。 Cloudflare 側の初期設定が先に要る。手順は docs/setup-cloudflare.md に書いた。

  1. Cloudflare アカウントと Zero Trust の初期設定(チームドメインを決める)
  2. Terraform 用 API トークンの発行
  3. Terraform state の置き場所を決める(R2 か HCP Terraform)
  4. Cloudflare の GitHub App をリポジトリにインストール、リポジトリ Secrets の登録
  5. terraform apply を手元で実行し、出力された D1 のデータベース ID を wrangler.d1.jsonc に貼る

課金は発生しないが、支払い方法の登録は 1 回必要になる。Pages / Functions / D1 だけならカードなしで使えるが、Zero Trust は無料プランでも支払い方法を求められる。

terraform apply の前に plan を確認してほしい。cloudflare_pages_project は更新が作り直しを引き起こす不具合が報告されていた(5.1 系、5.14 で修正)ので、forces replacement が出ていないか一度見ておきたい。

やっていないこと

  • 統計画面と設定画面の UI/api/study/stats/api/settings は動くが画面がない。1 日の枚数調整は当面 API 経由
  • カードの退場アニメーションの目視確認。検証環境のブラウザペインが非表示で rAF が回らず、退場中のカードが DOM に残ったままだった。ロジックは DOM 上で確認済みなので実ブラウザでは問題ないはずだが、断定はできない

🤖 Generated with Claude Code

hiramekun and others added 4 commits August 29, 2026 19:48
- Terraform で D1 / Cloudflare Access / Pages プロジェクトを定義する。Access の
  AUD タグは read-only 属性なので、Pages の環境変数へ Terraform から直接渡せる。
  ダッシュボードから手で控えて貼る作業と、その転記ミスがなくなる。
- ホスティングを GitHub Pages から Cloudflare Pages へ移す。ルートで配信されるので
  basePath の分岐は畳んだ。サイトのデプロイは Pages の Git 連携に任せ、
  CI が受け持つのは D1 の更新だけにする。
- D1 のテーブルは Terraform に持たせない。スキーマ変更の履歴はマイグレーションが
  持つべきもので、宣言的な差分検出とは相性が悪いため。
- wrangler.d1.jsonc をあえてその名前にしていないのは、Pages がルートの
  wrangler.jsonc を自動検出して Terraform 管理のバインディングと二重管理に
  なるのを避けるため。

sync-d1.yml が参照するマイグレーションと同期スクリプトは次のコミットで入る。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
出題スケジューラは FSRS-6 (ts-fsrs)。Anki 23.10 以降の既定アルゴリズムで、
左スワイプ = Again、右スワイプ = Good の 2 択をそのまま評価入力に写像する。

設計上の勘所:

- review_logs を原本、card_states をそこから作り直せる派生データとして扱う。
  この前提のおかげで、オフライン同期の順序ずれ・Undo・パラメータの再最適化が
  すべて rebuildCardState() ひとつに帰着する。
- 冪等性は (user_id, client_event_id) の一意索引で担保する。既知の ID は先に
  弾き、それでもすり抜けた重複は D1 の batch ごと失敗させる。batch は
  トランザクションなので、ログだけ入って状態が二重に進むことはない。
- タイムゾーンとロールオーバー(午前 4 時)の計算はアプリ側で行い、結果を
  study_day として保存する。SQLite にタイムゾーン付きの時刻型がないためだが、
  結果として日次の集計が索引一発になり、無料プランの CPU 10ms にも優しい。
- 認証は Cloudflare Access の JWT を検証する。Access を有効にしていても
  Functions に直接届く経路は残りうるので、署名のない
  Cf-Access-Authenticated-User-Email は使わない。

DDL の正は migrations/*.sql で、functions/_lib/schema.ts はそれを写した型定義。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/study はタイトルだけを見て中身を思い出し、答え合わせをしてから左右に
スワイプする画面。答えを見るまではスワイプさせない(見ずに評価しても
記録の意味がないため)。

- 未送信のレビューは IndexedDB のアウトボックスに溜め、オンラインに戻った
  ときにまとめて送る。冪等キーを持たせてあるので、再送で二重に反映される
  ことはない。IndexedDB が使えない環境ではメモリだけで動く。
- Cloudflare Access のセッション切れは、redirect: "manual" で投げて
  opaqueredirect を掴まえて検知する。素の fetch ではクロスオリジンの
  リダイレクトがネットワークエラーに見え、オフラインと区別できないため。
  ただしリダイレクトの原因が Access とは限らない(開発サーバの末尾スラッシュ
  リダイレクトなど)ので、リロードにクールダウンを設けて無限ループを避ける。
- Service Worker が /api/ をキャッシュしないようにする。古いキューを掴むと
  採点済みのカードがもう一度出てくるし、Access のリダイレクトを保存すると
  以後ずっと壊れた応答を返し続けることになる。

暗記度は安定度 S を対数で 0〜100 に写像して表示する。S をそのまま出すと
40 日と 200 日の差が伝わりにくいため。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
実際に terraform apply を通す過程で見つかった 4 点を修正する。

- Access の self-hosted アプリケーションは自分のアカウントのゾーンに属する
  ホスト名にしか掛けられない。*.pages.dev は Cloudflare 共有のドメインで
  ゾーンではないため対象にできず、独自ドメインが必須になる
  ("domain does not belong to zone")。app_hostname 変数を足し、
  cloudflare_pages_domain で Pages に繋ぐ形にした。設計時に
  「ドメインを買わずに済む」と書いていたのは誤りだった。
- read_replication を明示する。省略すると更新時にプロバイダが null を送り、
  API に 400 で弾かれる ("Expected object, received null")。
- deployment_configs を locals に切り出して production と preview の両方へ渡す。
  Pages の API は fail_open を両環境で揃えることを要求する。
- self_hosted_domains と deployments_enabled が非推奨になっていたので、
  destinations と production_deployments_enabled に移行する。あわせて
  preview_deployment_setting を none にした。プレビューの URL は Access の
  対象パスに含まれず保護の外に出るため。

wrangler.d1.jsonc には terraform output の D1 ID を入れた(秘密ではない)。
migrations_dir はトップレベルではなく D1 のエントリ内が正しい位置だった。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hiramekun
hiramekun merged commit be61944 into main Aug 29, 2026
@hiramekun
hiramekun deleted the feat/srs-cloudflare branch August 29, 2026 13:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant