diff --git a/.github/workflows/archive-closed-issue.yml b/.github/workflows/archive-closed-issue.yml index 99f595f..d19927a 100644 --- a/.github/workflows/archive-closed-issue.yml +++ b/.github/workflows/archive-closed-issue.yml @@ -88,8 +88,10 @@ jobs: git commit -m "Archive issue #${ISSUE_NUMBER}" git push origin "HEAD:${DEFAULT_BRANCH}" - - name: Trigger GitHub Pages deployment + # bot の push では workflow が連鎖しないので、明示的に叩く。 + # サイトのデプロイは Cloudflare Pages の Git 連携が拾う + - name: Trigger the D1 sync env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} GH_TOKEN: ${{ github.token }} - run: gh workflow run pages.yml --ref "${DEFAULT_BRANCH}" + run: gh workflow run sync-d1.yml --ref "${DEFAULT_BRANCH}" diff --git a/.github/workflows/merge-similar-notes.yml b/.github/workflows/merge-similar-notes.yml index 99749c1..0337f70 100644 --- a/.github/workflows/merge-similar-notes.yml +++ b/.github/workflows/merge-similar-notes.yml @@ -113,9 +113,11 @@ jobs: git pull --rebase origin "${DEFAULT_BRANCH}" git push origin "HEAD:${DEFAULT_BRANCH}" - - name: Trigger GitHub Pages deployment + # bot の push では workflow が連鎖しないので、明示的に叩く。 + # サイトのデプロイは Cloudflare Pages の Git 連携が拾う + - name: Trigger the D1 sync if: steps.apply.outputs.has_changes == 'true' env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} GH_TOKEN: ${{ github.token }} - run: gh workflow run pages.yml --ref "${DEFAULT_BRANCH}" + run: gh workflow run sync-d1.yml --ref "${DEFAULT_BRANCH}" diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml deleted file mode 100644 index 11b812d..0000000 --- a/.github/workflows/pages.yml +++ /dev/null @@ -1,66 +0,0 @@ -name: Deploy GitHub Pages - -on: - push: - branches: [main] - paths: - - "app/**" - - "components/**" - - "notes/**" - - "public/**" - - "scripts/build-notes-index.mjs" - - "package.json" - - "package-lock.json" - - "next.config.ts" - - "tsconfig.json" - - ".github/workflows/pages.yml" - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: pages-production - cancel-in-progress: false - -jobs: - build: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 24 - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Build static site - run: npm run build - - - name: Configure GitHub Pages - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - - - name: Upload GitHub Pages artifact - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 - with: - path: out - - deploy: - needs: build - runs-on: ubuntu-latest - timeout-minutes: 10 - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Deploy GitHub Pages - id: deployment - uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.github/workflows/sync-d1.yml b/.github/workflows/sync-d1.yml new file mode 100644 index 0000000..4d7366e --- /dev/null +++ b/.github/workflows/sync-d1.yml @@ -0,0 +1,54 @@ +name: Sync notes to D1 + +# サイト本体のデプロイは Cloudflare Pages の Git 連携が push を見て自動で行う。 +# この workflow が受け持つのは D1 の更新だけ。 +on: + push: + branches: [main] + paths: + - "notes/**" + - "migrations/**" + - "scripts/build-notes-index.mjs" + - "scripts/sync-notes-to-d1.mjs" + - ".github/workflows/sync-d1.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: sync-d1 + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + # 新しいスキーマを前提にしたコードが動き出す前に DB 側を整える + - name: Apply D1 migrations + run: npx wrangler d1 migrations apply tech-notes --remote -c wrangler.d1.jsonc + + - name: Build the notes index + run: node scripts/build-notes-index.mjs + + - name: Generate the sync SQL + run: node scripts/sync-notes-to-d1.mjs --out "${RUNNER_TEMP}/sync.sql" + + - name: Upsert notes and cards + run: npx wrangler d1 execute tech-notes --remote -c wrangler.d1.jsonc --yes --file="${RUNNER_TEMP}/sync.sql" diff --git a/.gitignore b/.gitignore index fccb0a5..e1ce8d3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,17 @@ out/ public/data/notes.json .closed-issue-context.json npm-debug.log* + +# Terraform +infra/.terraform/ +infra/*.tfstate +infra/*.tfstate.* +infra/terraform.tfvars +infra/backend.hcl +infra/.terraform.lock.hcl +.wrangler/ +tsconfig.tsbuildinfo + +# mise のローカル設定(認証情報を置く) +mise.local.toml +.mise.local.toml diff --git a/README.md b/README.md index b90b4b1..fa3f51b 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,12 @@ - issue をクローズすると、Claude Code Actions が本文と全コメントを読み直して1つの技術ノートへ再構成する - 後から追加された訂正・補足を反映し、重複した説明や会話形式のやり取りを整理する - 生成本文と、スクリプトで確定するメタデータを組み合わせて `notes/<ジャンル>/.md` に保存する -- GitHub Pages ではカードを左右へスワイプするか、左右の矢印キーで次のノートへ進める +- カードを左右へスワイプするか、左右の矢印キーで次のノートへ進める - 全カードを一巡するまでは同じカードを再表示せず、一巡後に再シャッフルする +サイトは Cloudflare Pages でホストする。カードの閲覧は誰でもできる公開ページで、 +学習機能(`/study` と `/api`)だけを Cloudflare Access で自分ひとりに絞っている。 + ローカルで確認する場合: ```bash @@ -31,13 +34,29 @@ npm install npm run dev ``` -### GitHub Pages の初期設定 +## 暗記モード + +`/study` は Anki のような間隔反復(SRS)で出題する画面。タイトルだけを見て中身を思い出し、 +答え合わせをしてから左右にスワイプする。 + +- **右スワイプ = 覚えている**(FSRS の Good)。安定度が伸び、次の出題まで間隔が開く +- **左スワイプ = 覚えていない**(FSRS の Again)。安定度が縮み、当日中にもう一度出る + +スケジューラは Anki 23.10 以降の既定アルゴリズムである FSRS-6(`ts-fsrs`)。 +学習状態は Cloudflare D1 に保存する。オフラインでもスワイプでき、復帰時にまとめて送られる。 + +設計の全体は [docs/setup-cloudflare.md](docs/setup-cloudflare.md) と、リポジトリ内の設計メモを参照。 + +### Cloudflare の初期設定 -1. リポジトリの **Settings → Pages** を開く -2. **Build and deployment → Source** で **GitHub Actions** を選ぶ -3. `Deploy GitHub Pages` workflow を手動実行するか、サイト関連ファイルを `main` にマージする +[docs/setup-cloudflare.md](docs/setup-cloudflare.md) を参照。ダッシュボードでの操作が要るのは +アカウントと Zero Trust の初期設定、API トークンの発行、GitHub App の連携まで。 +インフラそのものは `infra/` の Terraform で作る。 -通常の push では `.github/workflows/pages.yml` がサイトをデプロイする。issue のクローズ時は `.github/workflows/archive-closed-issue.yml` が Markdown の保存、コミット、サイトの再ビルド、デプロイまでを行う。 +サイトのデプロイは Cloudflare Pages の Git 連携が push を見て自動で行う。 +`notes/` が変わったときは `.github/workflows/sync-d1.yml` が D1 のマイグレーションと +ノートの同期を行う。issue のクローズ時は `.github/workflows/archive-closed-issue.yml` が +Markdown の保存とコミットまでを行い、その push が上記 2 つを起こす。 ## ラベル diff --git a/app/globals.css b/app/globals.css index 7909f74..b51594b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1440,3 +1440,101 @@ a { transition-duration: 0.01ms !important; } } + +/* ------------------------------------------------------------------ * + * 暗記モード (/study) + * 閲覧用のカードと同じ骨格を使い、想起 → 答え合わせ → 評価の導線だけ足す + * ------------------------------------------------------------------ */ + +.study-status-right { + display: flex; + align-items: center; + gap: 8px; +} + +.study-offline { + color: var(--md-sys-color-on-secondary-container); + background: var(--md-sys-color-secondary-container); +} + +/* 暗記度。0〜100 を 5 段階に丸めて色を変える */ +.study-mastery { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--md-sys-color-on-surface-variant); + font-size: var(--md-sys-typescale-label-small-size); + font-variant-numeric: tabular-nums; +} + +.study-mastery-bar { + display: block; + width: 64px; + height: 6px; + overflow: hidden; + border-radius: var(--md-sys-shape-corner-full); + background: var(--md-sys-color-surface-container-highest); +} + +.study-mastery-bar > span { + display: block; + height: 100%; + border-radius: inherit; + background: var(--md-sys-color-primary); + transition: width var(--md-sys-motion-duration-medium) var(--md-sys-motion-easing-standard); +} + +.study-mastery-bar[data-level="0"] > span, +.study-mastery-bar[data-level="1"] > span { + background: var(--md-sys-color-secondary); +} + +.study-mastery-bar[data-level="4"] > span { + background: var(--md-sys-color-tertiary); +} + +/* 表面: タイトルだけを見せて思い出してもらう */ +.study-prompt { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + min-height: 220px; + text-align: center; + color: var(--md-sys-color-on-surface-variant); +} + +.study-prompt small { + color: var(--md-sys-color-outline); + font-size: var(--md-sys-typescale-label-small-size); +} + +/* 裏面: ノート本文。長いので中だけスクロールさせる */ +.study-answer { + max-height: min(52vh, 520px); + overflow-y: auto; + overscroll-behavior: contain; +} + +.study-missing { + color: var(--md-sys-color-on-surface-variant); +} + +.study-forgot:not(:disabled) { + color: var(--md-sys-color-error); + border-color: var(--md-sys-color-error); +} + +.study-remembered:disabled, +.study-forgot:disabled { + cursor: not-allowed; + opacity: 0.38; +} + +.study-message { + margin: 12px auto 0; + color: var(--md-sys-color-on-surface-variant); + font-size: var(--md-sys-typescale-body-small-size); + text-align: center; +} diff --git a/app/study/page.tsx b/app/study/page.tsx new file mode 100644 index 0000000..0a78678 --- /dev/null +++ b/app/study/page.tsx @@ -0,0 +1,21 @@ +import { SiteHeader } from "@/components/site-header"; +import { StudyDeck } from "@/components/study-deck"; + +export const metadata = { + title: "Tech Notes 暗記", +}; + +export default function StudyPage() { + return ( +
+ + + + +
+ RECALL → GRADE → SCHEDULE + 覚えていれば右、思い出せなければ左。 +
+
+ ); +} diff --git a/components/study-deck.tsx b/components/study-deck.tsx new file mode 100644 index 0000000..95cc04b --- /dev/null +++ b/components/study-deck.tsx @@ -0,0 +1,423 @@ +"use client"; + +import { AnimatePresence, motion, useMotionValue, useTransform, type Variants } from "motion/react"; +import { BookOpen, Check, Eye, RotateCcw, WifiOff, X } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { MarkdownContent } from "@/components/markdown-content"; +import { + ApiError, + SessionExpiredError, + fetchQueue, + postReviews, + postUndo, + recoverFromExpiredSession, +} from "@/lib/study/api"; +import { enqueue, forget, pending } from "@/lib/study/outbox"; +import { SWIPE_RATING, type Counts, type PendingReview, type QueueCard } from "@/lib/study/types"; + +const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + +const SWIPE_DISTANCE = 110; +const SWIPE_VELOCITY = 520; +const QUEUE_SIZE = 20; +const REAUTH_FAILED = "サインインの状態を確認できませんでした。ページを開き直してください。"; + +type NoteEntry = { + id: string; + title: string; + category: string; + sourceUrl: string | null; + readingTime: number; + content: string; +}; + +const categoryNames: Record = { + frontend: "Frontend", + backend: "Backend", + infra: "Infrastructure", + database: "Database", + language: "Language", + "ai-ml": "AI / ML", + security: "Security", + devops: "DevOps", + architecture: "Architecture", + "cs-fundamentals": "CS Fundamentals", + uncategorized: "Knowledge", +}; + +const cardVariants: Variants = { + exit: (direction: number) => ({ + x: direction * 560, + opacity: 0, + rotate: direction * 10, + transition: { duration: 0.2, ease: [0.3, 0, 0.8, 0.15] }, + }), +}; + +/** 暗記度を 5 段階に丸める。生の % より、粒度が粗いほうが読みやすい */ +function masteryLevel(mastery: number) { + return Math.min(Math.floor(mastery / 20), 4); +} + +type StudyCardProps = { + card: QueueCard; + note: NoteEntry | undefined; + revealed: boolean; + direction: number; + onReveal: () => void; + onGrade: (rating: 1 | 3) => void; +}; + +function StudyCard({ card, note, revealed, direction, onReveal, onGrade }: StudyCardProps) { + const x = useMotionValue(0); + const rotate = useTransform(x, [-420, 0, 420], [-7, 0, 7]); + const opacity = useTransform(x, [-480, -220, 0, 220, 480], [0, 1, 1, 1, 0]); + const forgotOpacity = useTransform(x, [-150, -45, 0], [1, 0.25, 0]); + const rememberedOpacity = useTransform(x, [0, 45, 150], [0, 0.25, 1]); + + return ( + { + if (Math.abs(info.offset.x) >= SWIPE_DISTANCE || Math.abs(info.velocity.x) >= SWIPE_VELOCITY) { + onGrade(info.offset.x >= 0 ? SWIPE_RATING.right : SWIPE_RATING.left); + } + }} + aria-label={`${card.title} の暗記カード`} + > + {revealed ? ( + <> + + + + 覚えている + + ) : null} + +
+
+ {card.isNew ? "NEW CARD" : "REVIEW"} + {revealed ? "ANSWER" : "RECALL"} +
+
+ + {categoryNames[card.category] ?? card.category} + + + +
+

{card.front}

+
+ + {revealed ? ( +
+ {note ? ( + + ) : ( +

+ このカードの本文を読み込めませんでした。ノートの同期がまだかもしれません。 +

+ )} +
+ ) : ( +
+

タイトルだけを見て、中身を思い出してみる。

+ + スペースキーでも開けます +
+ )} +
+ ); +} + +export function StudyDeck() { + const [queue, setQueue] = useState([]); + const [notes, setNotes] = useState>(new Map()); + const [done, setDone] = useState({ new: 0, review: 0 }); + const [remaining, setRemaining] = useState({ new: 0, review: 0 }); + const [status, setStatus] = useState<"loading" | "ready" | "finished" | "error">("loading"); + const [revealed, setRevealed] = useState(false); + const [direction, setDirection] = useState(1); + const [offline, setOffline] = useState(false); + const [canUndo, setCanUndo] = useState(false); + const [message, setMessage] = useState(null); + + // カードが表示された時刻。下の useEffect で入る(レンダリング中に Date.now() は呼ばない) + const shownAt = useRef(0); + + /** 溜まっているレビューをまとめて送る。冪等なので何度呼んでも安全 */ + const flush = useCallback(async () => { + const outstanding = await pending(); + if (outstanding.length === 0) return; + + // 成功すれば送ったぶんは applied か skipped のどちらかに入る。 + // 失敗したときはアウトボックスに残して次の機会に送り直す + const batch = outstanding.slice(0, 50); + + try { + const response = await postReviews(batch); + await forget(batch.map((review) => review.clientEventId)); + setDone(response.done); + setRemaining(response.remaining); + setOffline(false); + } catch (error) { + if (error instanceof SessionExpiredError) { + if (!recoverFromExpiredSession()) setMessage(REAUTH_FAILED); + return; + } + if (error instanceof ApiError) { + setMessage(error.message); + return; + } + // ネットワークエラー。アウトボックスに残したまま次の機会に送る + setOffline(true); + } + }, []); + + const loadQueue = useCallback(async () => { + try { + const response = await fetchQueue(QUEUE_SIZE); + setQueue(response.cards); + setDone(response.done); + setRemaining(response.remaining); + setStatus(response.cards.length === 0 ? "finished" : "ready"); + setRevealed(false); + setOffline(false); + } catch (error) { + if (error instanceof SessionExpiredError && recoverFromExpiredSession()) return; + if (error instanceof SessionExpiredError) setMessage(REAUTH_FAILED); + setStatus("error"); + } + }, []); + + useEffect(() => { + let cancelled = false; + + void (async () => { + const loaded = await fetch(`${basePath}/data/notes.json`) + .then((response) => (response.ok ? (response.json() as Promise) : [])) + .catch(() => [] as NoteEntry[]); + + if (cancelled) return; + setNotes(new Map(loaded.map((note) => [note.id, note]))); + + await flush(); + if (!cancelled) await loadQueue(); + })(); + + return () => { + cancelled = true; + }; + }, [flush, loadQueue]); + + // オンラインに戻ったら溜めていたぶんを送る + useEffect(() => { + const onOnline = () => void flush(); + window.addEventListener("online", onOnline); + return () => window.removeEventListener("online", onOnline); + }, [flush]); + + const current = queue[0]; + + useEffect(() => { + shownAt.current = Date.now(); + }, [current?.cardId]); + + const grade = useCallback( + (rating: 1 | 3) => { + if (!current || !revealed) return; + + const review: PendingReview = { + clientEventId: crypto.randomUUID(), + cardId: current.cardId, + rating, + reviewedAt: Date.now(), + ...(shownAt.current ? { durationMs: Date.now() - shownAt.current } : {}), + }; + + setDirection(rating === SWIPE_RATING.right ? 1 : -1); + setRevealed(false); + setCanUndo(true); + setQueue((rest) => rest.slice(1)); + setDone((counts) => + current.isNew ? { ...counts, new: counts.new + 1 } : { ...counts, review: counts.review + 1 }, + ); + + void (async () => { + await enqueue(review); + await flush(); + })(); + }, + [current, flush, revealed], + ); + + // 手元のキューが尽きたら次を取りに行く。 + // 先に flush しておかないと、まだ届いていないレビューのぶんで同じカードが返ってくる + useEffect(() => { + if (status !== "ready" || queue.length > 0) return; + void (async () => { + await flush(); + await loadQueue(); + })(); + }, [flush, loadQueue, queue.length, status]); + + const undo = useCallback(async () => { + setCanUndo(false); + try { + await postUndo(); + setMessage("直前のレビューを取り消しました"); + await loadQueue(); + } catch (error) { + if (error instanceof SessionExpiredError) { + if (!recoverFromExpiredSession()) setMessage(REAUTH_FAILED); + return; + } + setMessage(error instanceof ApiError ? error.message : "取り消せませんでした"); + } + }, [loadQueue]); + + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if (event.key === " " && !revealed) { + event.preventDefault(); + setRevealed(true); + return; + } + if (!revealed) return; + if (event.key === "ArrowLeft") grade(SWIPE_RATING.left); + if (event.key === "ArrowRight") grade(SWIPE_RATING.right); + } + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [grade, revealed]); + + if (status === "loading") { + return ( +
+ +

今日のカードを準備中

+
+ ); + } + + if (status === "error") { + return ( +
+

キューを読み込めませんでした

+

{message ?? "通信の状態を確かめて、もう一度開いてください。"}

+ +
+ ); + } + + if (status === "finished" || !current) { + return ( +
+ {done.new + done.review} +

今日のぶんは終わりました

+

+ 新規 {done.new} 枚 / 復習 {done.review} 枚。次の期限が来たらまた出てきます。 +

+ + +
+ ); + } + + return ( +
+
+ + 残り 復習 {remaining.review} / 新規 {remaining.new} + + + {offline ? ( + + + ) : null} + + +
+ +
+
+
+ + setRevealed(true)} + onGrade={grade} + /> + +
+ +
+ +

+ {revealed ? "覚えていた?" : "まず思い出す"} + {revealed ? "左右にドラッグ / 矢印キー" : "スペースキーで答えを見る"} +

+ +
+ + {message ? ( +

+ {message} +

+ ) : null} +
+ ); +} diff --git a/docs/setup-cloudflare.md b/docs/setup-cloudflare.md new file mode 100644 index 0000000..998e40b --- /dev/null +++ b/docs/setup-cloudflare.md @@ -0,0 +1,153 @@ +# Cloudflare へのセットアップ手順 + +暗記モード(`/study`)を動かすまでの手順。ダッシュボードでの操作が必要なところと、 +コマンドで済むところを分けてある。所要はおおむね 45 分。 + +## 費用について + +**独自ドメインが必須**で、年 $10 前後かかる。Cloudflare Access の self-hosted アプリケーションは +自分のアカウントのゾーンに属するホスト名にしか掛けられず、`*.pages.dev` は Cloudflare 共有の +ドメインでゾーンではないため対象にできない(プレビューデプロイ向けの Access 連携は +`*.pages.dev` でも使えるが、本番の `.pages.dev` は保護できない)。 + +ドメイン代を除けば、Pages / Functions / D1 / Access はすべて無料枠に収まる。 +Cloudflare Registrar は原価販売で、取得するとゾーンが自動で作られるのでネームサーバの設定も要らない。 + +あわせて**支払い方法の登録が必要**になる。Pages / Functions / D1 だけならカードなしで使えるが、 +Zero Trust(Access)は無料プランを選ぶ場合でも支払い方法を求められる。 +R2 を Terraform の state 置き場に使う場合も同様 +(state を HCP Terraform の無料枠に置けば、カードが要るのは Zero Trust の 1 箇所だけになる)。 + +## 1. Cloudflare アカウントと Zero Trust(ダッシュボード) + +1. アカウントを作り、**Account ID** を控える。 +2. Zero Trust を有効化し、**チームドメイン**(`.cloudflareaccess.com`)を決める。 + Terraform では作れず、後から変えるのも面倒なので名前は慎重に。 +3. 無料プランを選び、支払い方法を登録する。 +4. **ドメインを用意する。** Domain Registration → Register Domains で取得すると、 + ゾーンが自動で作られる。すでに持っているドメインを使う場合は Websites → Add a site で + ゾーンとして追加し、ネームサーバを Cloudflare に向ける。 + `infra/terraform.tfvars` の `app_hostname` にサブドメイン(例: `notes.example.com`)を書く。 +4. ログイン方法を決める。 + - **One-time PIN**(メールに届くコードで認証)—— 追加設定なしで使える。ひとりで使うならこれが最小。 + - **Google** —— Google Cloud 側で OAuth クライアントを作り、client ID / secret を + Access の Identity provider に登録する。同意画面まわりは Terraform で完結しないので手作業が残る。 + +## 2. Terraform 用の API トークン(ダッシュボード) + +Account 単位で次の権限を持つトークンを発行する。 + +- D1 : Edit +- Cloudflare Pages : Edit +- Access: Apps and Policies : Edit + +発行した値は環境変数 `CLOUDFLARE_API_TOKEN` として渡す。tf ファイルには書かない。 + +## 3. Terraform の state 置き場(どちらか) + +- **R2** —— バケットを作り、R2 用のアクセスキーを発行する。`infra/backend.hcl.example` を + `backend.hcl` にコピーして bucket と endpoint を埋め、アクセスキーは + `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` で渡す。 +- **HCP Terraform の無料枠** —— カード登録が不要。`infra/main.tf` の `backend "s3"` ブロックを + `cloud {}` に差し替える。 + +## 4. GitHub 側(ダッシュボード + リポジトリ設定) + +1. Cloudflare の GitHub App をこのリポジトリにインストールする(Pages の Git 連携)。 +2. リポジトリの Secrets に登録する。 + - `CLOUDFLARE_API_TOKEN` + - `CLOUDFLARE_ACCOUNT_ID` + +## 認証情報の渡し方(mise) + +Terraform には 2 系統の認証情報が要る。state 置き場(R2 = S3 互換)とリソース作成 +(Cloudflare API)でクライアントが別だからで、前者は AWS SDK の作法に従うため変数名が `AWS_*` になる。 + +| | 何をする | 環境変数 | +|---|---|---| +| backend | R2 に state を読み書き | `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | +| provider | D1 / Pages / Access を作る | `CLOUDFLARE_API_TOKEN` | + +毎回 `export` しなくて済むように、プロジェクト直下の `mise.local.toml` に置く。 +このファイルは gitignore 済みで、コミットされない。 + +```toml +[env] +AWS_ACCESS_KEY_ID = "..." +AWS_SECRET_ACCESS_KEY = "..." +CLOUDFLARE_API_TOKEN = "..." +``` + +値を埋めたら一度だけ `mise trust` を実行する(mise は未信頼の設定ファイルを読まない)。 + +## 5. インフラを作る(手元で 1 回) + +```bash +cd infra +cp terraform.tfvars.example terraform.tfvars # account_id / allowed_email / team_domain を埋める +terraform init -backend-config=backend.hcl +terraform plan +terraform apply +``` + +`apply` が通ったら、出力された D1 のデータベース ID を `wrangler.d1.jsonc` の +`database_id` に貼ってコミットする(秘密の値ではない)。 + +```bash +terraform output d1_database_id +``` + +Access の AUD タグは Terraform が Pages の環境変数へ直接流し込むので、手で控える必要はない。 + +なお `terraform apply` の時点ではまだサイトは存在しない。Pages は `main` からビルドし、 +プレビューデプロイは無効にしてあるので、最初のデプロイが走るのは `main` にマージした後になる。 + +## 6. スキーマとノートを流し込む + +以降は `.github/workflows/sync-d1.yml` が自動で行うが、初回は手元から流してもよい。 + +```bash +npx wrangler d1 migrations apply tech-notes --remote -c wrangler.d1.jsonc +node scripts/build-notes-index.mjs +node scripts/sync-notes-to-d1.mjs --out /tmp/sync.sql +npx wrangler d1 execute tech-notes --remote -c wrangler.d1.jsonc --yes --file=/tmp/sync.sql +``` + +## ローカルでの動かし方 + +Functions と D1 を含めて動かすには `wrangler pages dev` を使う。`next dev` では +API が存在しないため、`/study` はエラー表示になる。 + +```bash +npm run build +npx wrangler d1 migrations apply tech-notes --local -c wrangler.d1.jsonc +node scripts/sync-notes-to-d1.mjs --out /tmp/sync.sql +npx wrangler d1 execute tech-notes --local -c wrangler.d1.jsonc --yes --file=/tmp/sync.sql +npx wrangler pages dev out \ + --d1 DB= \ + --binding LOCAL_DEV_EMAIL=you@example.com \ + --compatibility-date 2026-01-01 +``` + +`LOCAL_DEV_EMAIL` は Access の JWT 検証を飛ばすローカル専用の抜け道。 +**本番では絶対に設定しないこと。** Terraform の `env_vars` には含めていないので、 +ダッシュボードから手で足さないかぎり本番に現れることはない。 + +## 運用してから調整するもの + +- 1 日の枚数と目標保持率 —— `PATCH /api/settings` で変えられる(`user_settings` の値)。 + 最初の 2 週間は数字をいじりながら体に合わせる前提。 +- Access のセッション期間 —— `infra/variables.tf` の `session_duration`。 + 短いと通勤中の学習で再認証がたびたび挟まる。 + +## Terraform で踏んだ落とし穴 + +v5 プロバイダは OpenAPI から自動生成されており、「省略した属性を更新時に null で送ってしまう」 +系の不具合をいくつか持つ。作成は通るのに更新で 400 になるのが典型で、回避策は +**その属性を明示的に書く**こと。実際に踏んだのは次の 3 つで、いずれも対処済み。 + +| 症状 | 原因 | 対処 | +|---|---|---| +| `domain does not belong to zone` | Access は自ゾーンのホスト名しか受け付けない | `*.pages.dev` をやめて独自ドメインにした | +| `read_replication => Expected object, received null` | 省略した属性を更新時に null で送る | `read_replication = { mode = "disabled" }` を明示 | +| `fail_open` を production と preview で揃えろ | production しか定義していなかった | 設定を `locals` に切り出して両方へ渡す | diff --git a/eslint.config.mjs b/eslint.config.mjs index 1e17dd1..4c0634f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,5 +5,5 @@ import nextTypeScript from "eslint-config-next/typescript"; export default defineConfig([ ...nextVitals, ...nextTypeScript, - globalIgnores([".next/**", "out/**", "public/data/**"]), + globalIgnores([".next/**", "out/**", "public/data/**", ".wrangler/**"]), ]); diff --git a/functions/_lib/access.ts b/functions/_lib/access.ts new file mode 100644 index 0000000..0c35e96 --- /dev/null +++ b/functions/_lib/access.ts @@ -0,0 +1,70 @@ +import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"; + +import type { Env } from "./env"; +import { unauthorized } from "./http"; + +export interface AccessIdentity { + /** Access の JWT の sub。ユーザーの同一性はこれで判定する */ + sub: string; + email: string; +} + +/** + * JWKS はモジュールスコープに置く。同じ isolate に載っているあいだは + * 取得結果が再利用され、リクエストごとの subrequest にならない。 + */ +let jwks: ReturnType | null = null; + +function jwksFor(teamDomain: string) { + jwks ??= createRemoteJWKSet(new URL(`https://${teamDomain}/cdn-cgi/access/certs`)); + return jwks; +} + +function readCookie(request: Request, name: string): string | null { + const header = request.headers.get("cookie"); + if (!header) return null; + + for (const part of header.split(";")) { + const [key, ...rest] = part.trim().split("="); + if (key === name) return rest.join("="); + } + return null; +} + +/** + * Cloudflare Access が付けた JWT を検証して識別子を取り出す。 + * + * Access を有効にしていても Functions に直接届く経路は残りうるので、 + * 「Access が手前にいるから安全」と考えずに必ずここを通す。 + * 署名のない Cf-Access-Authenticated-User-Email は使わない。 + */ +export async function verifyAccessUser(request: Request, env: Env): Promise { + if (env.LOCAL_DEV_EMAIL) { + // ローカル開発時のみ。Env の説明を参照 + return { sub: `local-dev:${env.LOCAL_DEV_EMAIL}`, email: env.LOCAL_DEV_EMAIL }; + } + + const token = + request.headers.get("Cf-Access-Jwt-Assertion") ?? readCookie(request, "CF_Authorization"); + if (!token) { + throw unauthorized("Access のトークンがありません"); + } + + let payload: JWTPayload; + try { + ({ payload } = await jwtVerify(token, jwksFor(env.ACCESS_TEAM_DOMAIN), { + issuer: `https://${env.ACCESS_TEAM_DOMAIN}`, + audience: env.ACCESS_AUD, + })); + } catch { + throw unauthorized("Access のトークンを検証できませんでした"); + } + + const sub = payload.sub; + const email = typeof payload.email === "string" ? payload.email : null; + if (!sub || !email) { + throw unauthorized("Access のトークンに sub / email が含まれていません"); + } + + return { sub, email }; +} diff --git a/functions/_lib/db.ts b/functions/_lib/db.ts new file mode 100644 index 0000000..b069d41 --- /dev/null +++ b/functions/_lib/db.ts @@ -0,0 +1,10 @@ +import { drizzle } from "drizzle-orm/d1"; + +import type { Env } from "./env"; +import * as schema from "./schema"; + +export function getDb(env: Env) { + return drizzle(env.DB, { schema }); +} + +export type Db = ReturnType; diff --git a/functions/_lib/env.ts b/functions/_lib/env.ts new file mode 100644 index 0000000..1890e1f --- /dev/null +++ b/functions/_lib/env.ts @@ -0,0 +1,18 @@ +/// + +export interface Env { + /** D1 バインディング。Terraform の deployment_configs.d1_databases で結線する */ + DB: D1Database; + /** 例: example.cloudflareaccess.com */ + ACCESS_TEAM_DOMAIN: string; + /** Access アプリケーションの AUD タグ。Terraform が自動で流し込む */ + ACCESS_AUD: string; + /** + * ローカル開発でのみ使う抜け道。値が入っていると Access の JWT 検証を飛ばし、 + * このメールアドレスのユーザーとして扱う。 + * + * 本番では絶対に設定しないこと。Terraform の env_vars には含めていないので、 + * ダッシュボードから手で足さないかぎり本番に現れることはない。 + */ + LOCAL_DEV_EMAIL?: string; +} diff --git a/functions/_lib/http.ts b/functions/_lib/http.ts new file mode 100644 index 0000000..823b2a2 --- /dev/null +++ b/functions/_lib/http.ts @@ -0,0 +1,47 @@ +/** ハンドラから投げるとそのままレスポンスになるエラー */ +export class HttpError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + ) { + super(message); + this.name = "HttpError"; + } +} + +export const unauthorized = (message: string) => new HttpError(401, "unauthorized", message); +export const badRequest = (message: string) => new HttpError(400, "bad_request", message); +export const notFound = (message: string) => new HttpError(404, "not_found", message); + +export function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + "content-type": "application/json; charset=utf-8", + // 学習状態は常に最新を取りに行かせる。Service Worker にも拾わせない + "cache-control": "no-store", + }, + }); +} + +/** ハンドラを包んで、HttpError を JSON のエラー応答に変換する */ +export async function handle(fn: () => Promise): Promise { + try { + return await fn(); + } catch (error) { + if (error instanceof HttpError) { + return json({ error: error.code, message: error.message }, error.status); + } + console.error("unhandled error", error); + return json({ error: "internal", message: "サーバ側でエラーが発生しました" }, 500); + } +} + +export async function readJson(request: Request): Promise { + try { + return (await request.json()) as T; + } catch { + throw badRequest("リクエストボディが JSON として解釈できません"); + } +} diff --git a/functions/_lib/schema.ts b/functions/_lib/schema.ts new file mode 100644 index 0000000..0278a41 --- /dev/null +++ b/functions/_lib/schema.ts @@ -0,0 +1,129 @@ +/** + * Drizzle のスキーマ定義。 + * + * DDL の正は migrations/*.sql のほうで、ここはそれを写した型定義である。 + * カラムを足すときは必ずマイグレーションを先に書き、こちらを合わせること。 + */ +import { index, integer, primaryKey, real, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; + +export const notes = sqliteTable("notes", { + id: text("id").primaryKey(), + issueNumber: integer("issue_number"), + title: text("title").notNull(), + category: text("category").notNull(), + labels: text("labels").notNull().default("[]"), + sourceUrl: text("source_url"), + closedAt: integer("closed_at"), + contentHash: text("content_hash").notNull(), + updatedAt: integer("updated_at").notNull(), +}); + +export const cards = sqliteTable( + "cards", + { + id: text("id").primaryKey(), + noteId: text("note_id") + .notNull() + .references(() => notes.id, { onDelete: "cascade" }), + kind: text("kind", { enum: ["note", "qa", "cloze"] }) + .notNull() + .default("note"), + ordinal: integer("ordinal").notNull().default(0), + front: text("front").notNull(), + back: text("back").notNull().default(""), + contentHash: text("content_hash").notNull(), + contentUpdatedAt: integer("content_updated_at"), + retiredAt: integer("retired_at"), + createdAt: integer("created_at").notNull(), + }, + (t) => [ + uniqueIndex("cards_note_kind_ordinal_idx").on(t.noteId, t.kind, t.ordinal), + index("cards_note_idx").on(t.noteId), + ], +); + +export const users = sqliteTable("users", { + id: text("id").primaryKey(), + accessSub: text("access_sub").notNull().unique(), + email: text("email").notNull(), + createdAt: integer("created_at").notNull(), +}); + +export const userSettings = sqliteTable("user_settings", { + userId: text("user_id") + .primaryKey() + .references(() => users.id, { onDelete: "cascade" }), + timezone: text("timezone").notNull().default("Asia/Tokyo"), + dayStartHour: integer("day_start_hour").notNull().default(4), + desiredRetention: real("desired_retention").notNull().default(0.9), + newPerDay: integer("new_per_day").notNull().default(5), + reviewsPerDay: integer("reviews_per_day").notNull().default(60), + maxIntervalDays: integer("max_interval_days").notNull().default(365), + learningSteps: text("learning_steps").notNull().default('["1m","10m"]'), + relearningSteps: text("relearning_steps").notNull().default('["10m"]'), + fsrsParams: text("fsrs_params"), + updatedAt: integer("updated_at").notNull(), +}); + +export const cardStates = sqliteTable( + "card_states", + { + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + cardId: text("card_id") + .notNull() + .references(() => cards.id, { onDelete: "cascade" }), + state: text("state", { enum: ["new", "learning", "review", "relearning"] }) + .notNull() + .default("new"), + stability: real("stability"), + difficulty: real("difficulty"), + due: integer("due").notNull(), + lastReview: integer("last_review"), + scheduledDays: integer("scheduled_days").notNull().default(0), + elapsedDays: integer("elapsed_days").notNull().default(0), + learningSteps: integer("learning_steps").notNull().default(0), + reps: integer("reps").notNull().default(0), + lapses: integer("lapses").notNull().default(0), + suspended: integer("suspended").notNull().default(0), + updatedAt: integer("updated_at").notNull(), + }, + (t) => [primaryKey({ columns: [t.userId, t.cardId] })], +); + +export const reviewLogs = sqliteTable( + "review_logs", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + cardId: text("card_id") + .notNull() + .references(() => cards.id, { onDelete: "cascade" }), + clientEventId: text("client_event_id").notNull(), + rating: integer("rating").notNull(), + stateBefore: text("state_before").notNull(), + stabilityBefore: real("stability_before"), + difficultyBefore: real("difficulty_before"), + stabilityAfter: real("stability_after"), + difficultyAfter: real("difficulty_after"), + elapsedDays: integer("elapsed_days").notNull(), + scheduledDays: integer("scheduled_days").notNull(), + durationMs: integer("duration_ms"), + reviewedAt: integer("reviewed_at").notNull(), + studyDay: text("study_day").notNull(), + createdAt: integer("created_at").notNull(), + }, + (t) => [ + uniqueIndex("review_logs_idem_idx").on(t.userId, t.clientEventId), + index("review_logs_day_idx").on(t.userId, t.studyDay), + index("review_logs_replay_idx").on(t.userId, t.cardId, t.reviewedAt), + ], +); + +export type CardStateRow = typeof cardStates.$inferSelect; +export type ReviewLogRow = typeof reviewLogs.$inferSelect; +export type UserSettingsRow = typeof userSettings.$inferSelect; +export type CardRow = typeof cards.$inferSelect; diff --git a/functions/_lib/session.ts b/functions/_lib/session.ts new file mode 100644 index 0000000..2778873 --- /dev/null +++ b/functions/_lib/session.ts @@ -0,0 +1,12 @@ +import { verifyAccessUser } from "./access"; +import { getDb, type Db } from "./db"; +import type { Env } from "./env"; +import { currentUser, type SessionUser } from "./study"; + +/** 全エンドポイント共通の入り口。Access の検証とユーザー解決をまとめる */ +export async function openSession(request: Request, env: Env): Promise<{ db: Db; user: SessionUser }> { + const identity = await verifyAccessUser(request, env); + const db = getDb(env); + const user = await currentUser(db, identity); + return { db, user }; +} diff --git a/functions/_lib/srs.ts b/functions/_lib/srs.ts new file mode 100644 index 0000000..54a08d7 --- /dev/null +++ b/functions/_lib/srs.ts @@ -0,0 +1,165 @@ +/** + * FSRS まわりの純粋関数。DB には触らない。 + * サーバ側の計算を正とし、クライアントは同じ関数で楽観的に先行表示するだけ。 + */ +import { + Rating, + State, + createEmptyCard, + fsrs, + generatorParameters, + type Card, + type FSRS, + type Grade, + type StepUnit, +} from "ts-fsrs"; + +import type { CardStateRow, UserSettingsRow } from "./schema"; + +export type StateName = "new" | "learning" | "review" | "relearning"; + +/** 左スワイプ = 覚えていない / 右スワイプ = 覚えている */ +export const SWIPE_TO_RATING = { + left: Rating.Again, + right: Rating.Good, +} as const; + +const STATE_NAMES: readonly StateName[] = ["new", "learning", "review", "relearning"]; + +export function stateName(state: State): StateName { + return STATE_NAMES[state] ?? "new"; +} + +export function stateValue(name: string): State { + const index = STATE_NAMES.indexOf(name as StateName); + return index >= 0 ? (index as State) : State.New; +} + +export function isGrade(rating: number): rating is Grade { + return rating === Rating.Again || rating === Rating.Hard || rating === Rating.Good || rating === Rating.Easy; +} + +/** + * ロールオーバーを適用した「学習日」を YYYY-MM-DD で返す。 + * + * SQLite にタイムゾーン付きの時刻型がないので、この計算はアプリ側の責務。 + * 書き込み時に review_logs.study_day へ入れておくことで、日次の集計が索引一発になる。 + */ +export function studyDay(atMs: number, timezone: string, dayStartHour: number): string { + const shifted = new Date(atMs - dayStartHour * 3_600_000); + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(shifted); + + const pick = (type: Intl.DateTimeFormatPartTypes) => + parts.find((part) => part.type === type)?.value ?? ""; + + return `${pick("year")}-${pick("month")}-${pick("day")}`; +} + +/** + * 暗記度: 安定度 S(日) を対数で 0〜100 に写像する。S = 365 日 でちょうど 100。 + * S をそのまま出すと 40 日と 200 日の差が伝わりにくいため。 + */ +export function masteryPercent(stability: number | null): number { + if (!stability || stability <= 0) return 0; + return Math.round(100 * Math.min(1, Math.log1p(stability) / Math.log1p(365))); +} + +function parseSteps(raw: string, fallback: StepUnit[]): StepUnit[] { + try { + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.every((step) => typeof step === "string")) { + return parsed as StepUnit[]; + } + } catch { + // 壊れていたら既定値で続ける。学習を止めるほどの話ではない + } + return fallback; +} + +function parseParams(raw: string | null): number[] | undefined { + if (!raw) return undefined; + try { + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.every((value) => typeof value === "number")) { + return parsed as number[]; + } + } catch { + // 同上。既定パラメータにフォールバックする + } + return undefined; +} + +export function schedulerFor(settings: UserSettingsRow): FSRS { + const w = parseParams(settings.fsrsParams); + + return fsrs( + generatorParameters({ + request_retention: settings.desiredRetention, + maximum_interval: settings.maxIntervalDays, + ...(w ? { w } : {}), + // 復習日を数%ばらけさせ、同じ日に取り込んだノートが毎回まとまって出るのを防ぐ + enable_fuzz: true, + enable_short_term: true, + learning_steps: parseSteps(settings.learningSteps, ["1m", "10m"]), + relearning_steps: parseSteps(settings.relearningSteps, ["10m"]), + }), + ); +} + +export function toFsrsCard(row: Pick< + CardStateRow, + "state" | "stability" | "difficulty" | "due" | "lastReview" | "scheduledDays" | "elapsedDays" | "learningSteps" | "reps" | "lapses" +> | null): Card { + if (!row || row.state === "new") return createEmptyCard(); + + return { + due: new Date(row.due), + stability: row.stability ?? 0, + difficulty: row.difficulty ?? 0, + elapsed_days: row.elapsedDays, + scheduled_days: row.scheduledDays, + learning_steps: row.learningSteps, + reps: row.reps, + lapses: row.lapses, + state: stateValue(row.state), + ...(row.lastReview ? { last_review: new Date(row.lastReview) } : {}), + }; +} + +export interface PersistableCard { + state: StateName; + stability: number; + difficulty: number; + due: number; + lastReview: number | null; + scheduledDays: number; + elapsedDays: number; + learningSteps: number; + reps: number; + lapses: number; +} + +export function fromFsrsCard(card: Card): PersistableCard { + return { + state: stateName(card.state), + stability: card.stability, + difficulty: card.difficulty, + due: card.due.getTime(), + lastReview: card.last_review ? card.last_review.getTime() : null, + scheduledDays: card.scheduled_days, + elapsedDays: card.elapsed_days, + learningSteps: card.learning_steps, + reps: card.reps, + lapses: card.lapses, + }; +} + +/** いま出題したときの想起確率。カードに「思い出しやすさ」を出すのに使う */ +export function retrievabilityOf(scheduler: FSRS, card: Card, atMs: number): number { + return scheduler.get_retrievability(card, atMs, false); +} diff --git a/functions/_lib/study.ts b/functions/_lib/study.ts new file mode 100644 index 0000000..fb11229 --- /dev/null +++ b/functions/_lib/study.ts @@ -0,0 +1,427 @@ +/** + * 学習まわりの DB 操作。 + * + * 方針: review_logs が原本で、card_states はそこから作り直せる派生データ。 + * この前提のおかげで、オフライン同期の順序ずれ・Undo・パラメータ再最適化が + * すべて rebuildCardState() ひとつに帰着する。 + */ +import { and, asc, eq, inArray, isNull, lte, ne, sql } from "drizzle-orm"; +import type { BatchItem } from "drizzle-orm/batch"; +import type { Grade } from "ts-fsrs"; + +import type { Db } from "./db"; +import type { AccessIdentity } from "./access"; +import { badRequest } from "./http"; +import { cardStates, cards, notes, reviewLogs, userSettings, users } from "./schema"; +import type { UserSettingsRow } from "./schema"; +import { + fromFsrsCard, + isGrade, + masteryPercent, + retrievabilityOf, + schedulerFor, + studyDay, + toFsrsCard, +} from "./srs"; + +export interface SessionUser { + id: string; + email: string; + settings: UserSettingsRow; +} + +/** 初回アクセスがそのまま登録になる。サインアップ画面は存在しない */ +export async function currentUser(db: Db, identity: AccessIdentity): Promise { + const now = Date.now(); + + const [user] = await db + .insert(users) + .values({ id: crypto.randomUUID(), accessSub: identity.sub, email: identity.email, createdAt: now }) + .onConflictDoUpdate({ target: users.accessSub, set: { email: identity.email } }) + .returning(); + + if (!user) throw new Error("ユーザー行を作成できませんでした"); + + const [existing] = await db + .select() + .from(userSettings) + .where(eq(userSettings.userId, user.id)) + .limit(1); + + if (existing) return { id: user.id, email: user.email, settings: existing }; + + const [created] = await db + .insert(userSettings) + .values({ userId: user.id, updatedAt: now }) + .onConflictDoNothing() + .returning(); + + if (created) return { id: user.id, email: user.email, settings: created }; + + // 同時実行で先を越された場合。読み直せば必ずある + const [settings] = await db + .select() + .from(userSettings) + .where(eq(userSettings.userId, user.id)) + .limit(1); + if (!settings) throw new Error("設定行を作成できませんでした"); + + return { id: user.id, email: user.email, settings }; +} + +/** + * まだ card_states を持っていないカードに 'new' の行を用意する。 + * 同期ジョブはユーザーを知らないので、ここで遅延生成する。 + * 通常は 0 件で、ノートが増えた直後だけ数行動く。 + */ +export async function ensureCardStates(db: Db, userId: string, now: number): Promise { + await db.run(sql` + INSERT INTO card_states (user_id, card_id, state, due, updated_at) + SELECT ${userId}, c.id, 'new', ${now}, ${now} + FROM cards c + WHERE c.retired_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM card_states cs WHERE cs.user_id = ${userId} AND cs.card_id = c.id + ) + `); +} + +export interface DailyCounts { + newDone: number; + reviewDone: number; +} + +export async function dailyCounts(db: Db, userId: string, day: string): Promise { + const [row] = await db + .select({ + newDone: sql`coalesce(sum(case when ${reviewLogs.stateBefore} = 'new' then 1 else 0 end), 0)`, + reviewDone: sql`coalesce(sum(case when ${reviewLogs.stateBefore} <> 'new' then 1 else 0 end), 0)`, + }) + .from(reviewLogs) + .where(and(eq(reviewLogs.userId, userId), eq(reviewLogs.studyDay, day))); + + return { newDone: Number(row?.newDone ?? 0), reviewDone: Number(row?.reviewDone ?? 0) }; +} + +export interface QueueCard { + cardId: string; + noteId: string; + kind: string; + front: string; + title: string; + category: string; + state: string; + due: number; + mastery: number; + retrievability: number; + isNew: boolean; +} + +export async function buildQueue( + db: Db, + user: SessionUser, + now: number, + limit: number, +): Promise<{ cards: QueueCard[]; counts: DailyCounts; remaining: DailyCounts; day: string }> { + const { settings } = user; + const day = studyDay(now, settings.timezone, settings.dayStartHour); + const counts = await dailyCounts(db, user.id, day); + + const reviewLeft = Math.max(settings.reviewsPerDay - counts.reviewDone, 0); + const newLeft = Math.max(settings.newPerDay - counts.newDone, 0); + + const selection = { + cardId: cardStates.cardId, + noteId: cards.noteId, + kind: cards.kind, + front: cards.front, + title: notes.title, + category: notes.category, + state: cardStates.state, + due: cardStates.due, + stability: cardStates.stability, + difficulty: cardStates.difficulty, + lastReview: cardStates.lastReview, + scheduledDays: cardStates.scheduledDays, + elapsedDays: cardStates.elapsedDays, + learningSteps: cardStates.learningSteps, + reps: cardStates.reps, + lapses: cardStates.lapses, + }; + + const dueRows = reviewLeft + ? await db + .select(selection) + .from(cardStates) + .innerJoin(cards, eq(cards.id, cardStates.cardId)) + .innerJoin(notes, eq(notes.id, cards.noteId)) + .where( + and( + eq(cardStates.userId, user.id), + eq(cardStates.suspended, 0), + ne(cardStates.state, "new"), + lte(cardStates.due, now), + isNull(cards.retiredAt), + ), + ) + .orderBy(asc(cardStates.due)) + .limit(Math.min(reviewLeft, limit)) + : []; + + const newSlots = Math.min(newLeft, Math.max(limit - dueRows.length, 0)); + const newRows = newSlots + ? await db + .select(selection) + .from(cardStates) + .innerJoin(cards, eq(cards.id, cardStates.cardId)) + .innerJoin(notes, eq(notes.id, cards.noteId)) + .where( + and( + eq(cardStates.userId, user.id), + eq(cardStates.suspended, 0), + eq(cardStates.state, "new"), + isNull(cards.retiredAt), + ), + ) + .orderBy(sql`random()`) + .limit(newSlots) + : []; + + const scheduler = schedulerFor(settings); + const toQueueCard = (row: (typeof dueRows)[number]): QueueCard => ({ + cardId: row.cardId, + noteId: row.noteId, + kind: row.kind, + front: row.front, + title: row.title, + category: row.category, + state: row.state, + due: row.due, + mastery: masteryPercent(row.stability), + retrievability: row.state === "new" ? 0 : retrievabilityOf(scheduler, toFsrsCard(row), now), + isNew: row.state === "new", + }); + + return { + cards: [...dueRows, ...newRows].map(toQueueCard), + counts, + // 「今日あと何枚やれるか」。手元のキューに積んだぶんは差し引かない + // (/api/study/reviews が返す remaining と同じ意味に揃えてある) + remaining: { newDone: newLeft, reviewDone: reviewLeft }, + day, + }; +} + +export interface ReviewInput { + clientEventId: string; + cardId: string; + rating: number; + reviewedAt: number; + durationMs?: number; +} + +export interface AppliedState { + cardId: string; + state: string; + due: number; + mastery: number; +} + +const MAX_REVIEWS_PER_REQUEST = 50; + +export function parseReviews(payload: unknown): ReviewInput[] { + const list = (payload as { reviews?: unknown } | null)?.reviews; + if (!Array.isArray(list) || list.length === 0) { + throw badRequest("reviews が空です"); + } + if (list.length > MAX_REVIEWS_PER_REQUEST) { + // CPU 10ms の枠に収めるため、1 リクエストの件数を区切る + throw badRequest(`1 回に送れるレビューは ${MAX_REVIEWS_PER_REQUEST} 件までです`); + } + + return list.map((raw): ReviewInput => { + const item = raw as Partial; + if ( + typeof item.clientEventId !== "string" || + typeof item.cardId !== "string" || + typeof item.rating !== "number" || + typeof item.reviewedAt !== "number" + ) { + throw badRequest("レビューの形式が正しくありません"); + } + if (!isGrade(item.rating)) { + throw badRequest("rating は 1〜4 のいずれかです"); + } + return { + clientEventId: item.clientEventId, + cardId: item.cardId, + rating: item.rating, + reviewedAt: item.reviewedAt, + ...(typeof item.durationMs === "number" ? { durationMs: item.durationMs } : {}), + }; + }); +} + +/** + * レビューをまとめて反映する。 + * + * 冪等性は (user_id, client_event_id) の一意索引で担保する。既知の ID は先に + * 弾いておき、それでもすり抜けた重複はバッチごと失敗させる。D1 の batch は + * トランザクションなので、ログだけ入って状態が二重に進む状態にはならない。 + */ +export async function applyReviews( + db: Db, + user: SessionUser, + inputs: ReviewInput[], +): Promise<{ applied: AppliedState[]; skipped: string[] }> { + const { settings } = user; + const scheduler = schedulerFor(settings); + const now = Date.now(); + + const known = await db + .select({ clientEventId: reviewLogs.clientEventId }) + .from(reviewLogs) + .where( + and( + eq(reviewLogs.userId, user.id), + inArray( + reviewLogs.clientEventId, + inputs.map((input) => input.clientEventId), + ), + ), + ); + + const seen = new Set(known.map((row) => row.clientEventId)); + const fresh = inputs + .filter((input) => !seen.has(input.clientEventId)) + .sort((a, b) => a.reviewedAt - b.reviewedAt); + + if (fresh.length === 0) { + return { applied: [], skipped: inputs.map((input) => input.clientEventId) }; + } + + const cardIds = [...new Set(fresh.map((input) => input.cardId))]; + const stateRows = await db + .select() + .from(cardStates) + .where(and(eq(cardStates.userId, user.id), inArray(cardStates.cardId, cardIds))); + + const byCard = new Map(stateRows.map((row) => [row.cardId, row])); + const statements: BatchItem<"sqlite">[] = []; + const applied: AppliedState[] = []; + const needsRebuild = new Set(); + + for (const input of fresh) { + const before = byCard.get(input.cardId); + if (!before) throw badRequest(`未知のカードです: ${input.cardId}`); + + // 端末側の時計が既存の記録より過去 = オフラインぶんが遅れて届いた。 + // 逐次適用せず、あとでログから作り直す + if (before.lastReview !== null && input.reviewedAt < before.lastReview) { + needsRebuild.add(input.cardId); + } + + const fsrsCard = toFsrsCard(before); + const { card: after } = scheduler.next(fsrsCard, new Date(input.reviewedAt), input.rating as Grade); + const next = fromFsrsCard(after); + + statements.push( + db.insert(reviewLogs).values({ + id: crypto.randomUUID(), + userId: user.id, + cardId: input.cardId, + clientEventId: input.clientEventId, + rating: input.rating, + stateBefore: before.state, + stabilityBefore: before.stability, + difficultyBefore: before.difficulty, + stabilityAfter: next.stability, + difficultyAfter: next.difficulty, + elapsedDays: next.elapsedDays, + scheduledDays: next.scheduledDays, + durationMs: input.durationMs ?? null, + reviewedAt: input.reviewedAt, + studyDay: studyDay(input.reviewedAt, settings.timezone, settings.dayStartHour), + createdAt: now, + }), + ); + + statements.push( + db + .update(cardStates) + .set({ ...next, updatedAt: now }) + .where(and(eq(cardStates.userId, user.id), eq(cardStates.cardId, input.cardId))), + ); + + // 同じカードが同一バッチに複数回現れても正しく積み上がるようにする + byCard.set(input.cardId, { ...before, ...next, updatedAt: now }); + applied.push({ + cardId: input.cardId, + state: next.state, + due: next.due, + mastery: masteryPercent(next.stability), + }); + } + + await db.batch(statements as [BatchItem<"sqlite">, ...BatchItem<"sqlite">[]]); + + for (const cardId of needsRebuild) { + const rebuilt = await rebuildCardState(db, user, cardId); + const entry = applied.find((item) => item.cardId === cardId); + if (entry && rebuilt) { + entry.state = rebuilt.state; + entry.due = rebuilt.due; + entry.mastery = masteryPercent(rebuilt.stability); + } + } + + return { applied, skipped: [...seen] }; +} + +/** + * review_logs を時刻順に再生して card_states を作り直す。 + * + * オフライン同期の順序ずれ・Undo・FSRS パラメータの再最適化が、 + * すべてこの 1 関数に集まる。 + * + * なお review_logs の *_before / *_after は「計算した時点の記録」なので、 + * 再生時に書き戻さない。原本はあくまで rating と reviewed_at である。 + */ +export async function rebuildCardState(db: Db, user: SessionUser, cardId: string) { + const scheduler = schedulerFor(user.settings); + const logs = await db + .select({ rating: reviewLogs.rating, reviewedAt: reviewLogs.reviewedAt }) + .from(reviewLogs) + .where(and(eq(reviewLogs.userId, user.id), eq(reviewLogs.cardId, cardId))) + .orderBy(asc(reviewLogs.reviewedAt)); + + const now = Date.now(); + let card = toFsrsCard(null); + + for (const log of logs) { + if (!isGrade(log.rating)) continue; + ({ card } = scheduler.next(card, new Date(log.reviewedAt), log.rating)); + } + + const next = logs.length + ? fromFsrsCard(card) + : { + state: "new" as const, + stability: null, + difficulty: null, + due: now, + lastReview: null, + scheduledDays: 0, + elapsedDays: 0, + learningSteps: 0, + reps: 0, + lapses: 0, + }; + + await db + .update(cardStates) + .set({ ...next, updatedAt: now }) + .where(and(eq(cardStates.userId, user.id), eq(cardStates.cardId, cardId))); + + return next; +} diff --git a/functions/api/settings.ts b/functions/api/settings.ts new file mode 100644 index 0000000..2e5e50a --- /dev/null +++ b/functions/api/settings.ts @@ -0,0 +1,68 @@ +import { eq } from "drizzle-orm"; + +import type { Env } from "../_lib/env"; +import { badRequest, handle, json, readJson } from "../_lib/http"; +import { userSettings } from "../_lib/schema"; +import { openSession } from "../_lib/session"; + +export const onRequestGet: PagesFunction = async ({ request, env }) => + handle(async () => { + const { user } = await openSession(request, env); + return json(user.settings); + }); + +interface Patch { + timezone?: string; + dayStartHour?: number; + desiredRetention?: number; + newPerDay?: number; + reviewsPerDay?: number; + maxIntervalDays?: number; +} + +/** 範囲は migrations 側の CHECK 制約と合わせてある */ +const RANGES = { + dayStartHour: [0, 23], + desiredRetention: [0.7, 0.98], + newPerDay: [0, 100], + reviewsPerDay: [0, 500], + maxIntervalDays: [1, 36500], +} as const; + +export const onRequestPatch: PagesFunction = async ({ request, env }) => + handle(async () => { + const { db, user } = await openSession(request, env); + const patch = await readJson(request); + const update: Record = {}; + + if (patch.timezone !== undefined) { + if (typeof patch.timezone !== "string" || patch.timezone.length > 64) { + throw badRequest("timezone が不正です"); + } + try { + new Intl.DateTimeFormat("en-US", { timeZone: patch.timezone }); + } catch { + throw badRequest(`知らないタイムゾーンです: ${patch.timezone}`); + } + update.timezone = patch.timezone; + } + + for (const [key, [min, max]] of Object.entries(RANGES)) { + const value = patch[key as keyof typeof RANGES]; + if (value === undefined) continue; + if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) { + throw badRequest(`${key} は ${min} 〜 ${max} の範囲で指定してください`); + } + update[key] = key === "desiredRetention" ? value : Math.trunc(value); + } + + if (Object.keys(update).length === 0) throw badRequest("更新する項目がありません"); + + const [updated] = await db + .update(userSettings) + .set({ ...update, updatedAt: Date.now() }) + .where(eq(userSettings.userId, user.id)) + .returning(); + + return json(updated); + }); diff --git a/functions/api/study/queue.ts b/functions/api/study/queue.ts new file mode 100644 index 0000000..4b968f4 --- /dev/null +++ b/functions/api/study/queue.ts @@ -0,0 +1,28 @@ +import type { Env } from "../../_lib/env"; +import { handle, json } from "../../_lib/http"; +import { openSession } from "../../_lib/session"; +import { buildQueue, ensureCardStates } from "../../_lib/study"; + +const DEFAULT_LIMIT = 20; +const MAX_LIMIT = 50; + +export const onRequestGet: PagesFunction = async ({ request, env }) => + handle(async () => { + const { db, user } = await openSession(request, env); + + const requested = Number(new URL(request.url).searchParams.get("limit")); + const limit = Number.isFinite(requested) + ? Math.min(Math.max(Math.trunc(requested), 1), MAX_LIMIT) + : DEFAULT_LIMIT; + + const now = Date.now(); + await ensureCardStates(db, user.id, now); + const queue = await buildQueue(db, user, now, limit); + + return json({ + studyDay: queue.day, + done: { new: queue.counts.newDone, review: queue.counts.reviewDone }, + remaining: { new: queue.remaining.newDone, review: queue.remaining.reviewDone }, + cards: queue.cards, + }); + }); diff --git a/functions/api/study/reviews.ts b/functions/api/study/reviews.ts new file mode 100644 index 0000000..6499c10 --- /dev/null +++ b/functions/api/study/reviews.ts @@ -0,0 +1,29 @@ +import type { Env } from "../../_lib/env"; +import { handle, json, readJson } from "../../_lib/http"; +import { openSession } from "../../_lib/session"; +import { applyReviews, dailyCounts, parseReviews } from "../../_lib/study"; +import { studyDay } from "../../_lib/srs"; + +export const onRequestPost: PagesFunction = async ({ request, env }) => + handle(async () => { + const { db, user } = await openSession(request, env); + const reviews = parseReviews(await readJson(request)); + + const { applied, skipped } = await applyReviews(db, user, reviews); + + const { settings } = user; + const day = studyDay(Date.now(), settings.timezone, settings.dayStartHour); + const counts = await dailyCounts(db, user.id, day); + + return json({ + studyDay: day, + applied, + // 既に受け付け済みだったぶん。オフラインからの再送で普通に起きる + skipped, + done: { new: counts.newDone, review: counts.reviewDone }, + remaining: { + new: Math.max(settings.newPerDay - counts.newDone, 0), + review: Math.max(settings.reviewsPerDay - counts.reviewDone, 0), + }, + }); + }); diff --git a/functions/api/study/stats.ts b/functions/api/study/stats.ts new file mode 100644 index 0000000..14aefd4 --- /dev/null +++ b/functions/api/study/stats.ts @@ -0,0 +1,94 @@ +import { and, desc, eq, lte, ne, sql } from "drizzle-orm"; + +import type { Env } from "../../_lib/env"; +import { handle, json } from "../../_lib/http"; +import { cardStates, reviewLogs } from "../../_lib/schema"; +import { openSession } from "../../_lib/session"; +import { masteryPercent, studyDay } from "../../_lib/srs"; +import { dailyCounts } from "../../_lib/study"; + +/** 連続学習日数。ロールオーバー済みの study_day を新しい順にたどるだけ */ +function streakFrom(days: string[], today: string, yesterday: string): number { + if (days.length === 0) return 0; + + const first = days[0]; + if (first !== today && first !== yesterday) return 0; + + let streak = 1; + let cursor = new Date(`${first}T00:00:00Z`).getTime(); + + for (const day of days.slice(1)) { + const previous = new Date(cursor - 86_400_000).toISOString().slice(0, 10); + if (day !== previous) break; + streak += 1; + cursor = new Date(`${day}T00:00:00Z`).getTime(); + } + + return streak; +} + +export const onRequestGet: PagesFunction = async ({ request, env }) => + handle(async () => { + const { db, user } = await openSession(request, env); + const { settings } = user; + + const now = Date.now(); + const today = studyDay(now, settings.timezone, settings.dayStartHour); + const yesterday = studyDay(now - 86_400_000, settings.timezone, settings.dayStartHour); + + const counts = await dailyCounts(db, user.id, today); + + const [dueRow] = await db + .select({ count: sql`count(*)` }) + .from(cardStates) + .where( + and( + eq(cardStates.userId, user.id), + eq(cardStates.suspended, 0), + ne(cardStates.state, "new"), + lte(cardStates.due, now), + ), + ); + + const byState = await db + .select({ state: cardStates.state, count: sql`count(*)` }) + .from(cardStates) + .where(eq(cardStates.userId, user.id)) + .groupBy(cardStates.state); + + // 暗記度の分布はカード数が高々数百なので、素直に取って JS で刻む + const stabilities = await db + .select({ stability: cardStates.stability }) + .from(cardStates) + .where(and(eq(cardStates.userId, user.id), ne(cardStates.state, "new"))); + + const buckets = [0, 0, 0, 0, 0]; + for (const row of stabilities) { + const index = Math.min(Math.floor(masteryPercent(row.stability) / 20), 4); + buckets[index] = (buckets[index] ?? 0) + 1; + } + + const days = await db + .selectDistinct({ day: reviewLogs.studyDay }) + .from(reviewLogs) + .where(eq(reviewLogs.userId, user.id)) + .orderBy(desc(reviewLogs.studyDay)) + .limit(365); + + return json({ + studyDay: today, + done: { new: counts.newDone, review: counts.reviewDone }, + remaining: { + new: Math.max(settings.newPerDay - counts.newDone, 0), + review: Math.max(settings.reviewsPerDay - counts.reviewDone, 0), + }, + dueNow: Number(dueRow?.count ?? 0), + byState: Object.fromEntries(byState.map((row) => [row.state, Number(row.count)])), + masteryBuckets: buckets, + streak: streakFrom( + days.map((row) => row.day), + today, + yesterday, + ), + }); + }); diff --git a/functions/api/study/undo.ts b/functions/api/study/undo.ts new file mode 100644 index 0000000..2990ffd --- /dev/null +++ b/functions/api/study/undo.ts @@ -0,0 +1,36 @@ +import { and, desc, eq } from "drizzle-orm"; + +import type { Env } from "../../_lib/env"; +import { handle, json, notFound } from "../../_lib/http"; +import { reviewLogs } from "../../_lib/schema"; +import { openSession } from "../../_lib/session"; +import { rebuildCardState } from "../../_lib/study"; +import { masteryPercent } from "../../_lib/srs"; + +/** + * 直前のレビューを 1 件取り消す。 + * ログを消してから、そのカードの状態を残りのログから作り直すだけでよい。 + */ +export const onRequestPost: PagesFunction = async ({ request, env }) => + handle(async () => { + const { db, user } = await openSession(request, env); + + const [last] = await db + .select({ id: reviewLogs.id, cardId: reviewLogs.cardId }) + .from(reviewLogs) + .where(eq(reviewLogs.userId, user.id)) + .orderBy(desc(reviewLogs.reviewedAt)) + .limit(1); + + if (!last) throw notFound("取り消せるレビューがありません"); + + await db.delete(reviewLogs).where(and(eq(reviewLogs.userId, user.id), eq(reviewLogs.id, last.id))); + const restored = await rebuildCardState(db, user, last.cardId); + + return json({ + cardId: last.cardId, + state: restored.state, + due: restored.due, + mastery: masteryPercent(restored.stability), + }); + }); diff --git a/functions/tsconfig.json b/functions/tsconfig.json new file mode 100644 index 0000000..2521780 --- /dev/null +++ b/functions/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true + }, + "include": ["**/*.ts"] +} diff --git a/infra/backend.hcl.example b/infra/backend.hcl.example new file mode 100644 index 0000000..7674e8f --- /dev/null +++ b/infra/backend.hcl.example @@ -0,0 +1,6 @@ +# terraform init -backend-config=backend.hcl で渡す。 +# アクセスキーは環境変数 AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY で渡すこと。 +bucket = "tech-notes-tfstate" +endpoints = { + s3 = "https://ACCOUNT_ID.r2.cloudflarestorage.com" +} diff --git a/infra/main.tf b/infra/main.tf new file mode 100644 index 0000000..52bb685 --- /dev/null +++ b/infra/main.tf @@ -0,0 +1,155 @@ +terraform { + required_version = "~> 1.9" + + required_providers { + cloudflare = { + source = "cloudflare/cloudflare" + # v5 は OpenAPI から自動生成された全面書き換えで、v4 とは互換性がない。 + # 世に出回っているサンプルの多くは v4 なので必ず固定する。 + version = "~> 5.13" + } + } + + # state は R2(S3 互換)に置く。terraform init -backend-config=backend.hcl で + # bucket と endpoint を渡す。HCP Terraform の無料枠を使う場合はこのブロックごと + # cloud {} に差し替える。 + backend "s3" { + key = "prod/terraform.tfstate" + region = "auto" + + # R2 は S3 の一部の検証に応答しないので、まとめて無効化する + skip_credentials_validation = true + skip_region_validation = true + skip_requesting_account_id = true + skip_metadata_api_check = true + skip_s3_checksum = true + use_path_style = true + } +} + +# 認証は環境変数 CLOUDFLARE_API_TOKEN で渡す。tf ファイルには書かない +provider "cloudflare" {} + +locals { + # Access を掛けられるのは自分のゾーンのホスト名だけ。 + # .pages.dev は使えないので、カスタムドメインを正とする。 + host = var.app_hostname + + # Pages の API は production と preview で fail_open を揃えることを要求する + # ("You must set the `fail_open` property value equally...")。 + # 片方だけ書くと 400 になるので、同じ設定を両方に渡す。 + deployment_config = { + compatibility_date = var.compatibility_date + + # Function が落ちたときに静的アセットへフォールスルーさせない。 + # /api/* に該当する静的ファイルはないので実害はないが、挙動を決めておく + fail_open = false + + d1_databases = { + DB = { id = cloudflare_d1_database.main.id } + } + + env_vars = { + ACCESS_TEAM_DOMAIN = { + type = "plain_text" + value = var.team_domain + } + # aud は Access アプリケーションの read-only 属性。 + # ダッシュボードから手で控えて貼る作業がここで消える + ACCESS_AUD = { + type = "plain_text" + value = cloudflare_zero_trust_access_application.study.aud + } + } + } +} + +# --- データベース ------------------------------------------------------------ +# ここで作るのは器だけ。テーブルは wrangler d1 migrations の領分で、 +# Terraform には持たせない(スキーマ変更の履歴はマイグレーションが持つべきなので) +resource "cloudflare_d1_database" "main" { + account_id = var.account_id + name = var.project_name + + # 明示しないと、更新時にプロバイダが null を送って API に 400 で弾かれる + # ("Invalid property: read_replication => Expected object, received null")。 + # 単独利用でレプリカは要らないので disabled。 + read_replication = { + mode = "disabled" + } +} + +# --- アクセス制御 ------------------------------------------------------------ +resource "cloudflare_zero_trust_access_policy" "only_me" { + account_id = var.account_id + name = "${var.project_name}-only-me" + decision = "allow" + + # 「事前登録」の実体はこの 1 行。アプリ側にメールアドレスは持たせない + include = [{ email = { email = var.allowed_email } }] +} + +resource "cloudflare_zero_trust_access_application" "study" { + account_id = var.account_id + name = "${var.project_name} study" + type = "self_hosted" + domain = "${local.host}/api" + + # 1 つのアプリケーションで複数のパスを守れるので、AUD も 1 つで済む。 + # ここに挙げていないパス(カード閲覧などの公開ページ)は Access の外側に残る + destinations = [ + { type = "public", uri = "${local.host}/api" }, + { type = "public", uri = "${local.host}/study" }, + ] + + session_duration = var.session_duration + auto_redirect_to_identity = true + + policies = [{ + id = cloudflare_zero_trust_access_policy.only_me.id + precedence = 1 + }] +} + +# --- サイトと結線 ------------------------------------------------------------ +resource "cloudflare_pages_project" "site" { + account_id = var.account_id + name = var.project_name + production_branch = var.production_branch + + build_config = { + build_command = "npm run build" + destination_dir = "out" + } + + deployment_configs = { + production = local.deployment_config + # プレビューは無効化しているが、fail_open を揃えるために定義自体は要る + preview = local.deployment_config + } + + source = { + type = "github" + config = { + owner = var.github_owner + owner_id = var.github_owner_id + repo_name = var.github_repo + repo_id = var.github_repo_id + production_branch = var.production_branch + production_deployments_enabled = true + + # プレビューデプロイは作らない。.tech-notes.pages.dev のような URL は + # Access アプリケーションの対象パスに含まれず、保護の外側に出てしまうため。 + # (Functions 側の JWT 検証があるので 401 で止まるが、そもそも作らないほうが単純) + preview_deployment_setting = "none" + } + } +} + +# Pages プロジェクトにカスタムドメインを繋ぐ。 +# これで が Pages を指し、Access の対象にもなる。 +resource "cloudflare_pages_domain" "site" { + account_id = var.account_id + project_name = cloudflare_pages_project.site.name + name = var.app_hostname +} diff --git a/infra/outputs.tf b/infra/outputs.tf new file mode 100644 index 0000000..ae4e853 --- /dev/null +++ b/infra/outputs.tf @@ -0,0 +1,14 @@ +output "d1_database_id" { + description = "wrangler.d1.jsonc の database_id に貼る値" + value = cloudflare_d1_database.main.id +} + +output "access_aud" { + description = "Access アプリケーションの AUD タグ。Pages の環境変数へは Terraform が直接流し込む" + value = cloudflare_zero_trust_access_application.study.aud +} + +output "site_url" { + description = "公開 URL" + value = "https://${var.app_hostname}" +} diff --git a/infra/terraform.tfvars.example b/infra/terraform.tfvars.example new file mode 100644 index 0000000..ba8788b --- /dev/null +++ b/infra/terraform.tfvars.example @@ -0,0 +1,7 @@ +# cp terraform.tfvars.example terraform.tfvars して埋める。 +# terraform.tfvars は .gitignore 済み。 +account_id = "0123456789abcdef0123456789abcdef" +app_hostname = "notes.example.com" # Cloudflare のゾーンに属するホスト名 +allowed_email = "you@example.com" +team_domain = "your-team.cloudflareaccess.com" +github_owner = "hiramekun" diff --git a/infra/variables.tf b/infra/variables.tf new file mode 100644 index 0000000..8891ca6 --- /dev/null +++ b/infra/variables.tf @@ -0,0 +1,71 @@ +variable "account_id" { + type = string + description = "Cloudflare のアカウント ID" +} + +variable "project_name" { + type = string + description = "Pages プロジェクト名。.pages.dev がそのまま公開 URL になる" + default = "tech-notes" +} + +# Cloudflare Access の self-hosted アプリケーションは、自分のアカウントの +# ゾーンに属するホスト名にしか掛けられない。*.pages.dev は Cloudflare 共有の +# ドメインでゾーンではないため対象にできず、独自ドメインが必須になる。 +variable "app_hostname" { + type = string + description = "アプリを公開するホスト名 (例: notes.example.com)。Cloudflare のゾーンに属している必要がある" +} + +variable "allowed_email" { + type = string + description = "Access を通す Google アカウントのメールアドレス。これがアローリストの実体" +} + +variable "team_domain" { + type = string + description = "Zero Trust のチームドメイン (例: example.cloudflareaccess.com)" +} + +variable "github_owner" { + type = string + description = "Pages が参照する GitHub のオーナー名" +} + +variable "github_repo" { + type = string + description = "Pages が参照するリポジトリ名" + default = "tech-notes" +} + +# 数値 ID は秘密ではない。gh api repos// --jq '.id, .owner.id' で取れる。 +# owner / repo_name だけでは Pages がリポジトリを解決できないことがあるため明示しておく。 +variable "github_owner_id" { + type = string + description = "GitHub のオーナー ID" + default = "20180425" +} + +variable "github_repo_id" { + type = string + description = "GitHub のリポジトリ ID" + default = "1304750720" +} + +variable "production_branch" { + type = string + description = "本番デプロイの対象ブランチ" + default = "main" +} + +variable "compatibility_date" { + type = string + description = "Workers ランタイムの互換性日付" + default = "2026-01-01" +} + +variable "session_duration" { + type = string + description = "Access のセッション有効期間。毎日使う PWA なので長めに取る" + default = "720h" +} diff --git a/lib/study/api.ts b/lib/study/api.ts new file mode 100644 index 0000000..b97eda7 --- /dev/null +++ b/lib/study/api.ts @@ -0,0 +1,87 @@ +import type { PendingReview, QueueResponse, ReviewsResponse } from "./types"; + +/** + * Access のセッションが切れると、API への fetch にはログイン画面への + * リダイレクトが返る。クロスオリジンなので放っておくとネットワークエラーに + * 見えてしまい、「オフラインだ」と誤判定してしまう。 + * redirect: "manual" で投げて opaqueredirect を掴まえ、これを区別する。 + */ +export class SessionExpiredError extends Error { + constructor() { + super("Cloudflare Access のセッションが切れました"); + this.name = "SessionExpiredError"; + } +} + +export class ApiError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + this.name = "ApiError"; + } +} + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(path, { + ...init, + redirect: "manual", + cache: "no-store", + headers: init?.body ? { "content-type": "application/json", ...init.headers } : init?.headers, + }); + + if (response.type === "opaqueredirect" || response.status === 0 || response.status === 401) { + throw new SessionExpiredError(); + } + + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { message?: string } | null; + throw new ApiError(response.status, body?.message ?? `リクエストに失敗しました (${response.status})`); + } + + return (await response.json()) as T; +} + +const REAUTH_KEY = "tech-notes:reauth-at"; +const REAUTH_COOLDOWN_MS = 15_000; + +/** + * セッション切れは画面ごと再読み込みして Access に再認証させる。 + * + * ただしリダイレクトの原因が Access とは限らない(開発サーバの末尾スラッシュ + * リダイレクトなど)。無条件にリロードすると無限ループになるので、直前に + * 試したばかりなら諦めて false を返し、呼び出し側にメッセージを出させる。 + */ +export function recoverFromExpiredSession(): boolean { + try { + const last = Number(sessionStorage.getItem(REAUTH_KEY) ?? 0); + if (Date.now() - last < REAUTH_COOLDOWN_MS) return false; + sessionStorage.setItem(REAUTH_KEY, String(Date.now())); + } catch { + // sessionStorage が使えない環境でも、1 回はリロードを試す + } + + window.location.reload(); + return true; +} + +export function fetchQueue(limit = 20) { + // URL が毎回同じだとブラウザのメモリキャッシュから返ることがある。 + // レスポンスには no-store を付けているが、古いキューを掴むと + // 採点済みのカードがもう一度出てくるので、URL 側でも重ならないようにする + return request(`/api/study/queue?limit=${limit}&_t=${Date.now()}`); +} + +export function postReviews(reviews: PendingReview[]) { + return request("/api/study/reviews", { + method: "POST", + body: JSON.stringify({ reviews }), + }); +} + +export function postUndo() { + return request<{ cardId: string; state: string; due: number; mastery: number }>("/api/study/undo", { + method: "POST", + }); +} diff --git a/lib/study/outbox.ts b/lib/study/outbox.ts new file mode 100644 index 0000000..09900ac --- /dev/null +++ b/lib/study/outbox.ts @@ -0,0 +1,92 @@ +/** + * 未送信のレビューを端末に溜めておくアウトボックス。 + * + * 電波の切れる場所でもスワイプを止めないための仕組み。冪等キー + * (clientEventId) を持たせてあるので、復帰後にまとめて送り直しても二重に反映されない。 + * IndexedDB が使えない環境(プライベートウィンドウなど)ではメモリだけで動く。 + */ +import type { PendingReview } from "./types"; + +const DB_NAME = "tech-notes-study"; +const DB_VERSION = 1; +const STORE = "outbox"; + +let memoryFallback: PendingReview[] = []; + +function openDb(): Promise { + return new Promise((resolve) => { + if (typeof indexedDB === "undefined") return resolve(null); + + let request: IDBOpenDBRequest; + try { + request = indexedDB.open(DB_NAME, DB_VERSION); + } catch { + return resolve(null); + } + + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains(STORE)) { + request.result.createObjectStore(STORE, { keyPath: "clientEventId" }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => resolve(null); + }); +} + +async function withStore( + mode: IDBTransactionMode, + run: (store: IDBObjectStore) => IDBRequest, +): Promise { + const db = await openDb(); + if (!db) return null; + + return new Promise((resolve) => { + try { + const transaction = db.transaction(STORE, mode); + const request = run(transaction.objectStore(STORE)); + request.onsuccess = () => resolve(request.result); + request.onerror = () => resolve(null); + transaction.oncomplete = () => db.close(); + } catch { + resolve(null); + } + }); +} + +export async function enqueue(review: PendingReview): Promise { + const stored = await withStore("readwrite", (store) => store.put(review)); + if (stored === null) memoryFallback.push(review); +} + +export async function pending(): Promise { + const rows = await withStore("readonly", (store) => store.getAll()); + if (rows === null) return [...memoryFallback]; + return rows.sort((a, b) => a.reviewedAt - b.reviewedAt); +} + +export async function forget(clientEventIds: string[]): Promise { + if (clientEventIds.length === 0) return; + + const db = await openDb(); + if (!db) { + const done = new Set(clientEventIds); + memoryFallback = memoryFallback.filter((review) => !done.has(review.clientEventId)); + return; + } + + await new Promise((resolve) => { + try { + const transaction = db.transaction(STORE, "readwrite"); + const store = transaction.objectStore(STORE); + for (const id of clientEventIds) store.delete(id); + transaction.oncomplete = () => { + db.close(); + resolve(); + }; + transaction.onerror = () => resolve(); + } catch { + resolve(); + } + }); +} diff --git a/lib/study/types.ts b/lib/study/types.ts new file mode 100644 index 0000000..f373870 --- /dev/null +++ b/lib/study/types.ts @@ -0,0 +1,46 @@ +export type ReviewRating = 1 | 2 | 3 | 4; + +/** 左スワイプ = 覚えていない(Again) / 右スワイプ = 覚えている(Good) */ +export const SWIPE_RATING = { left: 1, right: 3 } as const satisfies Record; + +export interface QueueCard { + cardId: string; + noteId: string; + kind: string; + front: string; + title: string; + category: string; + state: string; + due: number; + mastery: number; + retrievability: number; + isNew: boolean; +} + +export interface Counts { + new: number; + review: number; +} + +export interface QueueResponse { + studyDay: string; + done: Counts; + remaining: Counts; + cards: QueueCard[]; +} + +export interface PendingReview { + clientEventId: string; + cardId: string; + rating: ReviewRating; + reviewedAt: number; + durationMs?: number; +} + +export interface ReviewsResponse { + studyDay: string; + applied: { cardId: string; state: string; due: number; mastery: number }[]; + skipped: string[]; + done: Counts; + remaining: Counts; +} diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql new file mode 100644 index 0000000..1430dc3 --- /dev/null +++ b/migrations/0001_init.sql @@ -0,0 +1,105 @@ +-- D1 (SQLite) の初期スキーマ。 +-- このファイルが DDL の正であり、functions/_lib/schema.ts はここを写した型定義。 +-- +-- SQLite にはタイムゾーン付きの時刻型がないため、時刻はすべて Unix epoch(ミリ秒)の +-- INTEGER で保持する。列挙型は TEXT + CHECK、配列は JSON 文字列で表す。 +-- id はアプリ側で crypto.randomUUID() を振る。 + +-- ノート: notes/*.md のミラー。source of truth はリポジトリ側 +CREATE TABLE notes ( + id TEXT PRIMARY KEY, -- build-notes-index.mjs の id と一致させる + issue_number INTEGER UNIQUE, + title TEXT NOT NULL, + category TEXT NOT NULL, + labels TEXT NOT NULL DEFAULT '[]', -- JSON 配列 + source_url TEXT, + closed_at INTEGER, + content_hash TEXT NOT NULL, -- 本文の SHA-256。差分検知に使う + updated_at INTEGER NOT NULL +); + +-- カード: 出題単位。Phase1 は 1 ノート = 1 カード、Phase2 で Q&A に分割する +CREATE TABLE cards ( + id TEXT PRIMARY KEY, + note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + kind TEXT NOT NULL DEFAULT 'note' CHECK (kind IN ('note','qa','cloze')), + ordinal INTEGER NOT NULL DEFAULT 0, + front TEXT NOT NULL, + back TEXT NOT NULL DEFAULT '', -- ノート単位カードでは空。本文は静的配信ぶんを使う + content_hash TEXT NOT NULL, + content_updated_at INTEGER, -- 本文が変わった時刻。暗記度は引き継ぐが記録は残す + retired_at INTEGER, -- 消さずに retire する(履歴を残すため) + created_at INTEGER NOT NULL, + UNIQUE (note_id, kind, ordinal) +); + +CREATE INDEX cards_note_idx ON cards (note_id); + +CREATE TABLE users ( + id TEXT PRIMARY KEY, + access_sub TEXT UNIQUE NOT NULL, -- Cloudflare Access の JWT の sub + email TEXT NOT NULL, -- 表示用。同一性の判定には使わない + created_at INTEGER NOT NULL +); + +CREATE TABLE user_settings ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + timezone TEXT NOT NULL DEFAULT 'Asia/Tokyo', + day_start_hour INTEGER NOT NULL DEFAULT 4 CHECK (day_start_hour BETWEEN 0 AND 23), + desired_retention REAL NOT NULL DEFAULT 0.90 CHECK (desired_retention BETWEEN 0.70 AND 0.98), + new_per_day INTEGER NOT NULL DEFAULT 5 CHECK (new_per_day BETWEEN 0 AND 100), + reviews_per_day INTEGER NOT NULL DEFAULT 60 CHECK (reviews_per_day BETWEEN 0 AND 500), + max_interval_days INTEGER NOT NULL DEFAULT 365 CHECK (max_interval_days BETWEEN 1 AND 36500), + learning_steps TEXT NOT NULL DEFAULT '["1m","10m"]', -- JSON 配列 + relearning_steps TEXT NOT NULL DEFAULT '["10m"]', -- JSON 配列 + fsrs_params TEXT, -- JSON 配列(21 個)。NULL なら既定値 + updated_at INTEGER NOT NULL +); + +-- ユーザー x カードの記憶状態(FSRS の Card に相当) +CREATE TABLE card_states ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + card_id TEXT NOT NULL REFERENCES cards(id) ON DELETE CASCADE, + state TEXT NOT NULL DEFAULT 'new' + CHECK (state IN ('new','learning','review','relearning')), + stability REAL, -- S(日)。new のうちは NULL + difficulty REAL, -- D(1〜10) + due INTEGER NOT NULL, + last_review INTEGER, + scheduled_days INTEGER NOT NULL DEFAULT 0, + elapsed_days INTEGER NOT NULL DEFAULT 0, + learning_steps INTEGER NOT NULL DEFAULT 0, -- FSRS の Card.learning_steps(何段目か) + reps INTEGER NOT NULL DEFAULT 0, + lapses INTEGER NOT NULL DEFAULT 0, + suspended INTEGER NOT NULL DEFAULT 0, -- 真偽値は 0 / 1 + updated_at INTEGER NOT NULL, + PRIMARY KEY (user_id, card_id) +); + +-- キュー取得はここだけがホットパス。SQLite も部分索引に対応している +CREATE INDEX card_states_due_idx ON card_states (user_id, due) WHERE suspended = 0; +CREATE INDEX card_states_new_idx ON card_states (user_id) WHERE state = 'new' AND suspended = 0; + +-- レビュー履歴。監査ログ兼「状態を作り直せる原本」 +CREATE TABLE review_logs ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + card_id TEXT NOT NULL REFERENCES cards(id) ON DELETE CASCADE, + client_event_id TEXT NOT NULL, -- オフライン再送の冪等キー + rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 4), + state_before TEXT NOT NULL, + stability_before REAL, + difficulty_before REAL, + stability_after REAL, + difficulty_after REAL, + elapsed_days INTEGER NOT NULL, + scheduled_days INTEGER NOT NULL, + duration_ms INTEGER, -- カード表示から判断までの時間 + reviewed_at INTEGER NOT NULL, -- 端末側の時刻(オフラインぶんは過去) + study_day TEXT NOT NULL, -- 'YYYY-MM-DD'。ロールオーバー適用済み + created_at INTEGER NOT NULL +); + +CREATE UNIQUE INDEX review_logs_idem_idx ON review_logs (user_id, client_event_id); +CREATE INDEX review_logs_day_idx ON review_logs (user_id, study_day); +CREATE INDEX review_logs_replay_idx ON review_logs (user_id, card_id, reviewed_at); diff --git a/next.config.ts b/next.config.ts index 18163fb..f936455 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,18 +1,12 @@ import type { NextConfig } from "next"; -const repositoryName = process.env.GITHUB_REPOSITORY?.split("/")[1] ?? ""; -const isProjectPage = - process.env.GITHUB_ACTIONS === "true" && - repositoryName.length > 0 && - !repositoryName.endsWith(".github.io"); -const basePath = isProjectPage ? `/${repositoryName}` : ""; - +// Cloudflare Pages はサイトをルートで配信するので basePath は要らない。 +// (GitHub Pages のプロジェクトページ向けに / を前置していた分岐は畳んだ) const nextConfig: NextConfig = { output: "export", trailingSlash: true, - basePath, env: { - NEXT_PUBLIC_BASE_PATH: basePath, + NEXT_PUBLIC_BASE_PATH: "", }, }; diff --git a/package-lock.json b/package-lock.json index a273460..e89a7f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,10 @@ "name": "tech-notes-cards", "version": "1.0.0", "dependencies": { + "drizzle-orm": "^0.45.2", "gray-matter": "4.0.3", "highlight.js": "11.11.1", + "jose": "^6.2.10", "lowlight": "3.3.0", "lucide-react": "1.25.0", "mermaid": "11.16.0", @@ -20,15 +22,18 @@ "react-markdown": "10.1.0", "rehype-highlight": "7.0.2", "rehype-sanitize": "6.0.0", - "remark-gfm": "4.0.1" + "remark-gfm": "4.0.1", + "ts-fsrs": "^5.4.1" }, "devDependencies": { + "@cloudflare/workers-types": "^5.20260829.1", "@types/node": "26.1.1", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "eslint": "9.39.5", "eslint-config-next": "16.2.11", - "typescript": "5.9.3" + "typescript": "5.9.3", + "wrangler": "^4.127.1" }, "engines": { "node": ">=20.9.0" @@ -299,6 +304,148 @@ "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", "license": "Apache-2.0" }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260828.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260828.1.tgz", + "integrity": "sha512-CVd+xPhqUESg8Xhq09TZx0wl4FSirfJGOzvbPz2yHhBIvmNHFFQkSN3rkd7wEwnhQQk37Xi0/aD6ykPLJbmGiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260828.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260828.1.tgz", + "integrity": "sha512-5HDPXRM152vU5JveByGFk34X57TVyIsfp4cabepAf45DC0MKvm52ucJqAjW1h8bvW4X+zRw9GU35OHF9FEC9Ww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260828.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260828.1.tgz", + "integrity": "sha512-MQ1Ll9P7F72HHUKizbb7BlDfbY8fRoNMpbIpZoU6uKsSkneFICWSKv6UlgU9EQZ+w0i7TMa12iUgJ8l29eRI9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260828.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260828.1.tgz", + "integrity": "sha512-FBTaUQ1xcU9jcp4OyBPcH8x0QiFvc1iuZL2GkD8zp2q1WyTVHYOptRDQUU+cuHjt0rQ2EIKVPBjahPxfa0joBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260828.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260828.1.tgz", + "integrity": "sha512-yvr77hC7dUbvK5K+SCg062kkPq3sx+drV1PcgHslzHDYcJBtT0V3X80qLE49LW1vq2svaeNmsVQS+vHsqWu8cQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "5.20260829.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260829.1.tgz", + "integrity": "sha512-SsH4G9+JePvrBmz+b5Dl67LQ9w2/ycoIpAPcAkfg2aEs2SEisQHk7/AY0TymSXEBbDbWgcBqoLe4hNcuY3CxWA==", + "devOptional": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -563,8 +710,8 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "devOptional": true, "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -1330,6 +1477,48 @@ "node": ">=12.4.0" } }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/dumper/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1337,6 +1526,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -2670,6 +2879,13 @@ "node": ">=6.0.0" } }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.16", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", @@ -2934,6 +3150,20 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cose-base": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", @@ -3625,8 +3855,8 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=8" } @@ -3666,6 +3896,131 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/drizzle-orm": { + "version": "0.45.2", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", + "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3695,6 +4050,16 @@ "dev": true, "license": "MIT" }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/es-abstract": { "version": "1.24.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", @@ -4542,6 +4907,21 @@ } } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5606,6 +5986,15 @@ "node": ">= 0.4" } }, + "node_modules/jose": { + "version": "6.2.10", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", + "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5748,6 +6137,16 @@ "node": ">=0.10.0" } }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", @@ -6804,6 +7203,24 @@ "node": ">=8.6" } }, + "node_modules/miniflare": { + "version": "5.20260828.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260828.0-alpha.tgz", + "integrity": "sha512-6nbxhZEcz/UET3Y1OnYPsrAUjUmuFoib3ynUqteRdn1YnDxsLg8cwgZJZCk9QmtOmGzXwzXzgE/d/C0dJAPtVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.2", + "undici": "7.29.0", + "workerd": "1.20260828.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -7266,6 +7683,20 @@ "dev": true, "license": "MIT" }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7844,8 +8275,8 @@ "version": "0.35.0", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.0.tgz", "integrity": "sha512-BqvG5XbwPZ4NV0DK90d86leEECMsoa8bO0nqnKWlBDYxri4GJ7c4EDInaF6q20lTh/mATmnDIKWJFfXnoVfH5g==", + "devOptional": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", @@ -7889,8 +8320,8 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "devOptional": true, "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, @@ -8388,6 +8819,15 @@ "node": ">=6.10" } }, + "node_modules/ts-fsrs": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ts-fsrs/-/ts-fsrs-5.4.1.tgz", + "integrity": "sha512-mOp9+oexJexBTkwjg/jQI1aSUQRLIAvbimeKHLSmVdNJPwObugFNKmZkoggH5d6kZ0uaWLboP1Al1DnXAfIb9w==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -8568,13 +9008,33 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "dev": true, "license": "MIT" }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -8911,6 +9371,569 @@ "node": ">=0.10.0" } }, + "node_modules/workerd": { + "version": "1.20260828.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260828.1.tgz", + "integrity": "sha512-pB9yvt0kkwZDAGZHmpY59r0o3hM0DzdW6BJERqwZOhunZ3ssOyDSgQxOQer2cSZW4YCFeOTIQYN1qwhK5wv/Cw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260828.1", + "@cloudflare/workerd-darwin-arm64": "1.20260828.1", + "@cloudflare/workerd-linux-64": "1.20260828.1", + "@cloudflare/workerd-linux-arm64": "1.20260828.1", + "@cloudflare/workerd-windows-64": "1.20260828.1" + } + }, + "node_modules/wrangler": { + "version": "4.127.1", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.127.1.tgz", + "integrity": "sha512-OzsiNgaI8i681L/+KnAKc+uEZ5D57xK5JuNvCOpRKICF4/5Q3Cu1oTGuUiT/f3GDUqQb3gzXNT0tfOHGMEtknw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260828.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260828.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260828.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/wrangler/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -8931,6 +9954,31 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/package.json b/package.json index 80cdf2e..0c99346 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,14 @@ "prebuild": "node scripts/build-notes-index.mjs", "build": "next build", "lint": "eslint .", - "test": "node --test scripts/*.test.mjs" + "test": "node --test scripts/*.test.mjs", + "typecheck": "tsc --noEmit && tsc --noEmit -p functions/tsconfig.json" }, "dependencies": { + "drizzle-orm": "0.45.2", "gray-matter": "4.0.3", "highlight.js": "11.11.1", + "jose": "6.2.10", "lowlight": "3.3.0", "lucide-react": "1.25.0", "mermaid": "11.16.0", @@ -23,15 +26,18 @@ "react-markdown": "10.1.0", "rehype-highlight": "7.0.2", "rehype-sanitize": "6.0.0", - "remark-gfm": "4.0.1" + "remark-gfm": "4.0.1", + "ts-fsrs": "5.4.1" }, "devDependencies": { + "@cloudflare/workers-types": "5.20260829.1", "@types/node": "26.1.1", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "eslint": "9.39.5", "eslint-config-next": "16.2.11", - "typescript": "5.9.3" + "typescript": "5.9.3", + "wrangler": "4.127.1" }, "overrides": { "postcss": "8.5.10", diff --git a/public/sw.js b/public/sw.js index 22a8321..0545439 100644 --- a/public/sw.js +++ b/public/sw.js @@ -5,9 +5,10 @@ * basePath は静的ファイルに埋め込めないため、登録時の scope から導出する。 * - ナビゲーション: network-first(最新を優先し、オフライン時はキャッシュへ) * - 同一オリジンの GET: stale-while-revalidate(表示は即座、裏で更新) + * - 学習 API (/api/): 一切触らない(下の fetch ハンドラの理由を参照) */ -const VERSION = "v1"; +const VERSION = "v2"; const CACHE = `tech-notes-${VERSION}`; // registration scope からベースパスを取り出す(末尾スラッシュは残す) @@ -36,7 +37,17 @@ self.addEventListener("activate", (event) => { self.addEventListener("fetch", (event) => { const { request } = event; - if (request.method !== "GET" || new URL(request.url).origin !== self.location.origin) { + const url = new URL(request.url); + + if (request.method !== "GET" || url.origin !== self.location.origin) { + return; + } + + // 学習 API はキャッシュも横取りもしない。 + // 古いキューを掴むと採点済みのカードがもう一度出てくるし、 + // Cloudflare Access のセッション切れで返るリダイレクトを保存してしまうと、 + // 以後ずっと壊れた応答を返し続けることになる。 + if (url.pathname.startsWith(`${SCOPE_PATH}api/`)) { return; } @@ -45,8 +56,11 @@ self.addEventListener("fetch", (event) => { event.respondWith( fetch(request) .then((response) => { - const copy = response.clone(); - caches.open(CACHE).then((cache) => cache.put(request, copy)); + // リダイレクトやエラー応答は残さない(Access のログイン画面を掴まないため) + if (response.ok && response.type === "basic" && !response.redirected) { + const copy = response.clone(); + caches.open(CACHE).then((cache) => cache.put(request, copy)); + } return response; }) .catch(async () => (await caches.match(request)) ?? (await caches.match(APP_SHELL)) ?? Response.error()), @@ -59,7 +73,7 @@ self.addEventListener("fetch", (event) => { caches.match(request).then((cached) => { const network = fetch(request) .then((response) => { - if (response.ok) { + if (response.ok && response.type === "basic") { const copy = response.clone(); caches.open(CACHE).then((cache) => cache.put(request, copy)); } diff --git a/scripts/sync-notes-to-d1.mjs b/scripts/sync-notes-to-d1.mjs new file mode 100644 index 0000000..a65058c --- /dev/null +++ b/scripts/sync-notes-to-d1.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node +/** + * public/data/notes.json から D1 用の同期 SQL を生成する。 + * + * node scripts/build-notes-index.mjs + * node scripts/sync-notes-to-d1.mjs --out /tmp/sync.sql + * npx wrangler d1 execute tech-notes --remote -c wrangler.d1.jsonc --file=/tmp/sync.sql + * + * 本文そのものは D1 に入れない。入れるのはメタデータと content_hash だけで、 + * 本文は従来どおり静的ファイルとして配信する。 + * + * ノートの本文が変わってもカードの暗記度は引き継ぐ。同期がするのは + * content_hash と content_updated_at の更新までで、card_states には触らない。 + */ + +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const projectRoot = process.cwd(); +const notesJsonPath = path.join(projectRoot, "public", "data", "notes.json"); + +/** SQL の文字列リテラル。NULL と数値以外はすべてここを通す */ +export function quote(value) { + if (value === null || value === undefined) return "NULL"; + return `'${String(value).replaceAll("'", "''")}'`; +} + +export function toEpochMs(value) { + if (!value) return "NULL"; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? "NULL" : String(parsed); +} + +export function contentHash(content) { + return createHash("sha256").update(content ?? "", "utf8").digest("hex"); +} + +/** Phase1 は 1 ノート = 1 カード。id は読める形で固定しておく */ +export function cardIdFor(noteId) { + return `${noteId}:note:0`; +} + +export function buildStatements(notes, now) { + const statements = []; + + for (const note of notes) { + const hash = contentHash(note.content); + + statements.push( + `INSERT INTO notes (id, issue_number, title, category, labels, source_url, closed_at, content_hash, updated_at)\n` + + `VALUES (${quote(note.id)}, ${note.issueNumber ?? "NULL"}, ${quote(note.title)}, ${quote(note.category)}, ` + + `${quote(JSON.stringify(note.labels ?? []))}, ${quote(note.sourceUrl)}, ${toEpochMs(note.closedAt)}, ` + + `${quote(hash)}, ${now})\n` + + `ON CONFLICT(id) DO UPDATE SET\n` + + ` issue_number = excluded.issue_number,\n` + + ` title = excluded.title,\n` + + ` category = excluded.category,\n` + + ` labels = excluded.labels,\n` + + ` source_url = excluded.source_url,\n` + + ` closed_at = excluded.closed_at,\n` + + ` content_hash = excluded.content_hash,\n` + + ` updated_at = excluded.updated_at;`, + ); + + statements.push( + `INSERT INTO cards (id, note_id, kind, ordinal, front, back, content_hash, content_updated_at, created_at)\n` + + `VALUES (${quote(cardIdFor(note.id))}, ${quote(note.id)}, 'note', 0, ${quote(note.title)}, '', ${quote(hash)}, NULL, ${now})\n` + + `ON CONFLICT(note_id, kind, ordinal) DO UPDATE SET\n` + + ` front = excluded.front,\n` + + ` content_hash = excluded.content_hash,\n` + + // 本文が変わったことは記録するが、暗記度はそのまま引き継ぐ + ` content_updated_at = CASE WHEN cards.content_hash <> excluded.content_hash THEN ${now} ELSE cards.content_updated_at END,\n` + + ` retired_at = NULL;`, + ); + } + + // リポジトリから消えたノートのカードは、消さずに retire する + const ids = notes.map((note) => quote(note.id)).join(", "); + statements.push( + ids.length > 0 + ? `UPDATE cards SET retired_at = ${now} WHERE retired_at IS NULL AND note_id NOT IN (${ids});` + : `UPDATE cards SET retired_at = ${now} WHERE retired_at IS NULL;`, + ); + + return statements; +} + +async function main() { + const outIndex = process.argv.indexOf("--out"); + const outPath = outIndex >= 0 ? process.argv[outIndex + 1] : null; + if (!outPath) { + console.error("使い方: node scripts/sync-notes-to-d1.mjs --out "); + process.exit(1); + } + + const notes = JSON.parse(await readFile(notesJsonPath, "utf8")); + const statements = buildStatements(notes, Date.now()); + const sql = `-- scripts/sync-notes-to-d1.mjs が生成\n${statements.join("\n\n")}\n`; + + await writeFile(outPath, sql, "utf8"); + console.log(`Wrote ${outPath} (${notes.length} note(s), ${statements.length} statement(s)).`); +} + +const isDirectExecution = process.argv[1] + ? import.meta.url === pathToFileURL(process.argv[1]).href + : false; + +if (isDirectExecution) { + await main(); +} diff --git a/scripts/sync-notes-to-d1.test.mjs b/scripts/sync-notes-to-d1.test.mjs new file mode 100644 index 0000000..246f030 --- /dev/null +++ b/scripts/sync-notes-to-d1.test.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildStatements, cardIdFor, contentHash, quote, toEpochMs } from "./sync-notes-to-d1.mjs"; + +test("quote はシングルクォートをエスケープする", () => { + assert.equal(quote("it's"), "'it''s'"); + assert.equal(quote(null), "NULL"); + assert.equal(quote(undefined), "NULL"); +}); + +test("toEpochMs は ISO 文字列をミリ秒に変換し、壊れた値は NULL にする", () => { + assert.equal(toEpochMs("2026-07-23T11:40:27Z"), String(Date.parse("2026-07-23T11:40:27Z"))); + assert.equal(toEpochMs(""), "NULL"); + assert.equal(toEpochMs("not a date"), "NULL"); +}); + +test("contentHash は同じ本文で安定し、変わると別の値になる", () => { + assert.equal(contentHash("abc"), contentHash("abc")); + assert.notEqual(contentHash("abc"), contentHash("abd")); +}); + +test("cardIdFor はノート ID から決まる", () => { + assert.equal(cardIdFor("26"), "26:note:0"); +}); + +test("buildStatements はノートごとに notes と cards の upsert を出し、最後に retire を足す", () => { + const statements = buildStatements( + [ + { + id: "26", + issueNumber: 26, + title: "Bigtable の使い所", + category: "database", + labels: ["database", "type:comparison"], + sourceUrl: "https://example.com/26", + closedAt: "2026-07-23T11:40:27Z", + content: "本文", + }, + ], + 1_700_000_000_000, + ); + + assert.equal(statements.length, 3); + assert.match(statements[0], /^INSERT INTO notes /); + assert.match(statements[0], /ON CONFLICT\(id\) DO UPDATE SET/); + assert.match(statements[1], /^INSERT INTO cards /); + assert.match(statements[1], /ON CONFLICT\(note_id, kind, ordinal\) DO UPDATE SET/); + assert.match(statements[2], /UPDATE cards SET retired_at = 1700000000000 WHERE retired_at IS NULL AND note_id NOT IN \('26'\);/); +}); + +test("本文が変わったときだけ content_updated_at を進める", () => { + const [, cardStatement] = buildStatements( + [{ id: "7", title: "t", category: "infra", labels: [], content: "x" }], + 1234, + ); + + assert.match( + cardStatement, + /content_updated_at = CASE WHEN cards\.content_hash <> excluded\.content_hash THEN 1234 ELSE cards\.content_updated_at END/, + ); +}); + +test("ノートが 0 件なら全カードを retire する", () => { + const statements = buildStatements([], 99); + assert.equal(statements.length, 1); + assert.equal(statements[0], "UPDATE cards SET retired_at = 99 WHERE retired_at IS NULL;"); +}); diff --git a/tsconfig.json b/tsconfig.json index e585d9d..4bd4ce8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,6 +36,7 @@ ".next/dev/types/**/*.ts" ], "exclude": [ - "node_modules" + "node_modules", + "functions" ] } diff --git a/wrangler.d1.jsonc b/wrangler.d1.jsonc new file mode 100644 index 0000000..b2fa87f --- /dev/null +++ b/wrangler.d1.jsonc @@ -0,0 +1,21 @@ +{ + // D1 のマイグレーション適用専用の設定。 + // npx wrangler d1 migrations apply tech-notes --remote -c wrangler.d1.jsonc + // + // ファイル名を wrangler.jsonc にしていないのは意図的。Cloudflare Pages は + // ルートの wrangler.toml / wrangler.json / wrangler.jsonc を自動検出して + // ランタイム設定として使うため、置くと Terraform が管理している + // バインディングと環境変数が二重管理になる。バインディングの正は Terraform 側。 + "name": "tech-notes", + "compatibility_date": "2026-01-01", + "d1_databases": [ + { + "binding": "DB", + "database_name": "tech-notes", + // migrations_dir はトップレベルではなく D1 のエントリ内に書く + "migrations_dir": "migrations", + // terraform output d1_database_id の値。秘密ではないのでコミットする + "database_id": "684adf8e-4b06-4461-8d19-7de517e365fc" + } + ] +}