MOOBON
PWA Implementation ・ Rails 8 / IndexedDB / Offline

Rails 8 の PWA を オフライン対応 にした話IndexedDB と、既存フローを壊さない独立モードの設計(Phase 3+4)

2026年5月14日 公開読了 約 14 分MOOBON 技術ブログ

本記事は 「Rails 8 の PWA を実装した話」(Phase 1+2) の続編です。Phase 1+2 で Service Worker を入れて、アプリの土台になる部分(アイコンやオフライン用ページ、一度開いた画面など)をキャッシュしましたが、それだけでは 「オフラインで次の問題に進めない」中途半端な体験にしかなりません。問題データそのものはサーバーにあるからです。

Phase 3+4 では、IndexedDB に問題データを持たせ既存のサーバー駆動フローを一切壊さずに「オフライン専用練習モード」を独立した経路として新設します。本記事の核心は IndexedDB の使い方そのものよりも、既存システムを壊さずにオフライン機能を足す設計判断の方です。

対象環境
Rails
8.1
フロント
Hotwire (Turbo + Stimulus)
クライアント
IndexedDB(スキーマ v2)
対象範囲
Phase 3(問題データの IndexedDB 保持)+ Phase 4(オフライン専用練習モード)。前提は Phase 1+2(PWA / Service Worker)

はじめに ─ Service Worker だけではオフライン学習にならなかった

前回(Phase 1+2)で Service Worker を入れ、オフラインでも白いエラー画面ではなくフォールバックページが出るようにしました。ただ、実際にオフラインで使うと 1 問目で詰みます。「次の問題へ」を押すたびにサーバーへ問題を取りに行くからです。

そこで、問題データそのものをクライアント側(IndexedDB)に持たせるのが Phase 3、オフラインで解いた回答を端末にためて、オンライン復帰時に同期するのが Phase 4 です。

本記事はこの Phase 3+4 を扱います。対象は「で、オフラインで実際に何ができるの?」と思った方と、既存のサーバー駆動 UI を壊さずにオフライン機能を足したい Rails エンジニア。IndexedDB 入門ではなく、設計判断を中心に書きます。

1. 設計方針 ── 既存を流用し、オフライン専用経路だけ足す

1-1. 既存の学習機能をそのまま使わなかった理由

このアプリには元々「学習セッション(study_session)」という中心機能があります。「次に出すべき問題」をサーバー側で毎回計算する作りで、ユーザの正答率や苦手分野を見て出題を最適化するロジックが Rails 側に乗っています。

# 既存の StudySessionsController#show のイメージ(汎用化)
def show
  @session  = current_user.study_sessions.find(params[:id])
  @question = Question.weak_for_user(current_user)  # サーバー側で都度計算
  # ...
end

なので「次の問題へ」を押すたびに サーバー往復が前提になっています。これをそのままオフラインで動かそうとすると、出題最適化のロジックごとクライアント側に移植することになり、実質、フロントを丸ごと作り直す大改修です。影響範囲が大きく、既存の UX を壊すリスクも高い ── 正直、ここに手を入れるのは避けたいところでした。

そこで方針を切り替えました。既存フローには一切触らず、オフライン専用の別経路を新しく足す。オフライン練習は「ランダムに出して解く軽い復習」と割り切り、出題最適化が要る本格的な学習は従来どおりサーバー駆動のセッションに任せる。要は 2 つの経路を住み分けさせる、という判断です。詳しくは §3-1 で書きます。

1-2. 全体像 ── 何を新しく作り、何は既存を流用するか

実装に入る前に、Phase 3+4 で何を新しく作り、何は既存のまま流用するかを一望しておきます。「既存を壊さない」方針なので、サーバー側はほとんど流用で済み、新規追加はオフライン専用の部品に絞られるのが見てとれます。

やること新規 / 既存
ルーティングquestions#index を JSON でも返す既存流用(ルート追加なし)
ルーティングoffline_practice / offline_attempts(member 2 本)新規
サーバーQuestionsController に JSON 用メソッド既存に追加
サーバーOfflinePracticeController(show / bulk_create)新規
クライアント・保存db.js / question_store.js(IndexedDB)新規
クライアント・同期offline_sync_controller.js(裏で自動 sync)新規
クライアント・練習 UIoffline_practice_controller.js / show.html.erb新規

これを 2 つの Phase に割り振ります。Phase 3 はデータの土台(問題を JSON で渡す口 → IndexedDB に保存 → 裏で自動同期)、Phase 4 はページ本体(/exams/sap/offline_practice という新しい練習ページ + 回答キュー + 一括同期)です。§1 で触れた「オフライン専用の別経路」の正体が、この offline_practice ページになります。

2. Phase 3 ── 問題データを IndexedDB に持たせる

Phase 3 では、まず データの土台だけを作ります。画面への組み込みは Phase 4 に回して、ここでは「サーバーから問題データを JSON でもらう → IndexedDB に保存 → オフラインでも読み出せる」ところまでを用意します。

2-1. サーバー側:questions コントローラに JSON 用メソッドを追加

まず、問題データを JSON で配信するエンドポイントが要ります。/api/v1/... のような独立 namespace ではなく、既存の QuestionsController#indexrespond_to で JSON を足すだけにしました。URL は /exams/sap/questions.json です。

# app/controllers/questions_controller.rb
def index
  @questions = @exam.questions.order(:position)
  respond_to do |format|
    format.html
    format.json { render json: questions_json_payload(@questions) }
  end
end

private

def questions_json_payload(questions)
  locale = I18n.locale.to_s
  {
    exam: { id: @exam.id, slug: @exam.slug, name: @exam.name },
    locale: locale,
    generated_at: Time.current.iso8601,
    questions: questions.includes(:tags).map { |q|
      {
        id: q.id,
        position: q.position,
        domain: q.domain,
        body: q.localized_body(locale),
        choices: q.localized_choices(locale),
        correct_answers: q.correct_answers,
        source_explanation: q.localized_explanation(locale),
        tags: q.tags.map(&:name)
      }
    }
  }
end

ここで意識したのは次の 3 点です。

  • includes(:tags) で N+1 を回避 ── 全問をループしてタグを引くと N+1 になる。一括取得しておく
  • correct_answers(正解)を含める ── オフラインでローカル採点するために必要。正解は「回答後に表示される情報」なので機密ではない、という整理(FAQ 参照)
  • 単一 locale で送る ── このアプリは 3 言語対応だが、3 言語 × 全問を一度に送るとペイロードが重い。ユーザが locale を切り替える頻度は低いので、現在の locale 分だけ送り、切り替え時に再 sync する。meta ストアに locale を記録しておく

2-2. クライアント側:問題を IndexedDB に保存する処理を自作する

IndexedDB のラッパーは idbDexie を使うのが一般的ですが、今回は 入れませんでした。扱うストアは questionsmeta の 2 つだけで、自前でも 100 行未満。それ以上に、importmap-rails 環境で npm 依存を増やすとビルド構成に影響するのが大きく、この規模なら自前ラッパーの方が見通しが良いと判断しました。

importmap に pin_all_from "app/javascript/offline", under: "offline" を足して、offline/question_store として import できるようにします。スキーマは questions ストア(keyPath: "id")と meta ストア(keyPath: "key")の 2 つです。

// app/javascript/offline/question_store.js(Phase 3 時点)
const DB_NAME = "app"
const DB_VERSION = 1
const QUESTIONS_STORE = "questions"
const META_STORE = "meta"

function openDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION)
    request.onupgradeneeded = (event) => {
      const db = event.target.result
      const store = db.createObjectStore(QUESTIONS_STORE, { keyPath: "id" })
      store.createIndex("exam_slug", "exam_slug", { unique: false })
      store.createIndex("position", "position", { unique: false })
      db.createObjectStore(META_STORE, { keyPath: "key" })
    }
    request.onsuccess = () => resolve(request.result)
    request.onerror = () => reject(request.error)
  })
}

export async function saveQuestions(examSlug, questions, locale) {
  // transaction で全件 put、meta に sync 時刻と件数を記録
}

2-3. Stimulus 同期コントローラ(silent sync)

データの取得と保存をいつ走らせるか。オンラインでアプリを開いたとき、最終同期から 1 時間以上たっていたら自動で fetch する方式にしました。

これを offline_sync_controller.js という Stimulus controller にして、ログイン後のレイアウト全体に張ります。ナビ直後に <div data-controller="offline-sync" data-offline-sync-exam-slug-value="sap"> を置くだけです。

  • connect()navigator.onLine をチェック
  • オンラインなら最終同期時刻を確認し、1 時間以上経過なら fetch("/exams/sap/questions.json")saveQuestions()
  • windowonline / offline イベントを購読して、オフラインバナーを出し分け
// app/javascript/controllers/offline_sync_controller.js
import { Controller } from "@hotwired/stimulus"
import { saveQuestions, countQuestions, getSyncMeta } from "offline/question_store"

const SYNC_INTERVAL_MS = 60 * 60 * 1000 // 1 時間

export default class extends Controller {
  static values = { examSlug: String }

  connect() {
    window.addEventListener("online",  () => this.maybeSync())
    window.addEventListener("offline", () => this.handleOffline())
    navigator.onLine ? this.maybeSync() : this.handleOffline()
  }

  // 最終同期から 1 時間以上たっていたら取りに行く
  async maybeSync() {
    const meta = await getSyncMeta(this.examSlugValue)
    const lastSync = meta?.synced_at ? Date.parse(meta.synced_at) : 0
    if (Date.now() - lastSync < SYNC_INTERVAL_MS) return
    await this.runSync()
  }

  async runSync() {
    const slug = this.examSlugValue
    const res = await fetch(`/exams/${slug}/questions.json`, {
      credentials: "same-origin",
      headers: { "Accept": "application/json" },
    })
    if (!res.ok) return
    const payload = await res.json()
    await saveQuestions(slug, payload.questions || [], payload.locale)
    console.info(`[offline-sync] saved ${payload.questions?.length || 0} questions for ${slug}`)
  }

  // handleOffline() で「○○ 問が端末に保存済み」バナーを出す処理は省略
}

ユーザに何も意識させず、裏で問題データを最新化しておく ── いわば silent sync です。おかげで、オフライン練習を始めようとした時点では、たいてい問題データはもう端末にある状態にできます。

3. Phase 4 ── オフライン専用練習モード

Phase 3 でデータレイヤーが揃ったので、Phase 4 で 「オフラインで問題を解く UI」と「回答キュー + 一括同期」を作ります。

3-1. オフライン専用の練習ページを新設する

§1 の方針どおり、既存フローには触れず 別経路 /exams/:slug/offline_practice を新設します。Rails でページを 1 つ作るので、コントローラ・ビュー・JS・ルーティングを 1 セットで用意します。各部品と、それを解説する節は次のとおりです。

部品ファイル役割解説
ルーティングroutes.rb(member 2 本)URL を生やす§3-3
コントローラOfflinePracticeControllerページ表示 + 回答受け取り§3-3
ビューoffline_practice/show.html.erbstart / question / result の 3 ビューを持つ HTML シェル§3-4
JS(Stimulus)offline_practice_controller.js出題・採点・回答キュー・同期を動かす§3-4
データ層db.js / question_store.js / attempt_queue.js問題と回答を IndexedDB に出し入れ(Phase 3 で作成)§3-2

動作面では、このページは次のような作りです。

  • 問題は IndexedDB から出す(サーバーに取りに行かない)
  • 画面は HTML シェル + クライアントレンダリング
  • 回答はその場で送らず、IndexedDB のキューに溜めて後でまとめて送る
  • 採点は その場でクライアント側 → 同期時にサーバーが再評価

3-2. 回答も IndexedDB に貯める

オフラインで解いた回答は、オンラインに復帰して同期するまで 端末に貯めておく必要があります。これも IndexedDB に、回答用の attempts_queue ストアを作って溜めます。

ここで、§2-2 で作った「問題を読み書きする処理」に加えて、この「回答を貯める処理」も同じ IndexedDB を使うことになります。つまり 同じ DB を使うコードが 2 つになります。

IndexedDB では、ストアを作れるのは「初回(またはバージョンを上げたとき)に 1 度だけ走る初期化」の中だけです。もし 2 つのモジュールがそれぞれ別々に DB を開くと、先に開いた方の初期化しか走らず、もう一方が作るはずのストアができません

そこで、ストアの定義と DB を開く処理を app/javascript/offline/db.js の 1 か所にまとめ、両モジュールはそこから同じ DB を受け取って使います。これなら初期化は 1 回で済み、ストアも全部そろいます。

注意:ストアを増やすには DB のバージョンを上げる

ストアを増やす(attempts_queue の追加)には、IndexedDB の バージョンを上げる必要があります。Phase 3 の時点で既に v1(questions + meta)を配ってしまっているので、v2 に上げて、既存ユーザ(v1 を持っている)と新規ユーザの両方を 1 つのコードで移行します。

// app/javascript/offline/db.js
const DB_NAME = "app"
const DB_VERSION = 2
let dbPromise = null

export function openDB() {
  if (dbPromise) return dbPromise
  dbPromise = new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION)
    request.onupgradeneeded = (event) => {
      const db = event.target.result
      const oldVersion = event.oldVersion
      if (oldVersion < 1) {
        // v1 スキーマ(questions + meta)
      }
      if (oldVersion < 2) {
        // v2 で追加:attempts_queue(autoIncrement key)
        const attempts = db.createObjectStore("attempts_queue", {
          keyPath: "id", autoIncrement: true
        })
        attempts.createIndex("exam_slug", "exam_slug", { unique: false })
        attempts.createIndex("attempted_at", "attempted_at", { unique: false })
      }
    }
    request.onsuccess = () => resolve(request.result)
    request.onerror = () => reject(request.error)
  })
  return dbPromise
}

oldVersion < 1oldVersion < 2 を独立した if で書くのがポイントです。新規ユーザ(v0 → v2)は両方の if を通り、既存ユーザ(v1 → v2)は v2 の差分だけ通る。1 つのコードで両方のユーザをカバーできます。dbPromise をモジュールスコープにキャッシュして、複数回 openDB() を呼んでも接続は 1 つに保ちます。

共通化した db.js の上に、回答を貯める attempt_queue.js を作ります。autoIncrement: true の id を採用したので、サーバーに送らない端末ローカルな id 体系を持てます。同期に成功したら、その id でキューから削除します。

// app/javascript/offline/attempt_queue.js
import { withStore, ATTEMPTS_QUEUE_STORE } from "offline/db"

export async function enqueueAttempt({
  examSlug, questionId, selectedAnswers, correct, timeSpentSeconds
}) {
  return withStore(ATTEMPTS_QUEUE_STORE, "readwrite", (store) =>
    new Promise((resolve, reject) => {
      const request = store.add({
        exam_slug: examSlug,
        question_id: questionId,
        selected_answers: selectedAnswers,
        correct: !!correct,
        time_spent_seconds: timeSpentSeconds || null,
        attempted_at: new Date().toISOString(),
      })
      request.onsuccess = () => resolve(request.result)
      request.onerror = () => reject(request.error)
    })
  )
}

// countQueuedAttempts / getQueuedAttempts / removeAttempts も同様

attempted_at回答した時刻を記録しておくのが重要です。オフラインで解いてから同期するまでに時間が空くので、同期した時刻ではなく解いた時刻を残します。これはサーバー側で created_at に反映します(§3-3)。

3-3. サーバー側:一括同期エンドポイント

キューに溜まった回答を受け取る bulk_create を作ります。ルートは、オフライン練習ページを表示する GET と、溜まった回答を受け取る POST の 2 本です。

# config/routes.rb
get  :offline_practice,  to: "offline_practice#show",        on: :member
post :offline_attempts,  to: "offline_practice#bulk_create", on: :member
# app/controllers/offline_practice_controller.rb
def bulk_create
  items = Array(params[:attempts])
  saved = skipped = 0

  Attempt.transaction do
    items.each do |item|
      question = @exam.questions.find_by(id: item[:question_id])
      next (skipped += 1) unless question
      attempt = current_user.attempts.new(
        question: question,
        selected_answers: Array(item[:selected_answers]).reject(&:blank?),
        time_spent_seconds: item[:time_spent_seconds].presence&.to_i,
      )
      # オフライン中の実時刻を created_at に保つ
      attempt.created_at = item[:attempted_at] if item[:attempted_at].present?
      attempt.save ? (saved += 1) : (skipped += 1)
    end
  end

  render json: { ok: true, saved: saved, skipped: skipped }
end

ここでのポイントは 2 つです。

  • 採点はサーバーで再評価 ── クライアントから届く correct は信用せず、保存時に Attempt モデル(before_save)が採点し直します
  • 1 件の不整合で同期全体を止めない ── 不正な question_id などは skip して件数を数えるだけ。残りは Attempt.transaction でまとめて保存します

3-4. オフライン練習の画面を作る

オフライン練習の画面は、show.html.erb同一ページ内に start / question / result の 3 つのビューを置き、Stimulus controller が classList.toggle("hidden") で切り替えます。Turbo Frames は使いません。Turbo Frame はサーバー往復が前提なので、オフラインで動く必要があるこの画面とは相性が悪いためです。

ビューいつ表示中身
start練習を始める前「○○ 問が利用可能です」+「練習を始める」
question問題を解いている間問題文・選択肢・「回答する」
result回答した後正解 / 不正解・「次の問題」

サーバーから controller へのデータ受け渡しは data-* 属性で行います。同期先 URL、CSRF トークン、i18n 文字列などを埋め込みます。

<div data-controller="offline-practice"
     data-offline-practice-exam-slug-value="<%= @exam.slug %>"
     data-offline-practice-sync-url-value="<%= offline_attempts_exam_path(@exam) %>"
     data-offline-practice-csrf-token-value="<%= form_authenticity_token %>"
     data-correct-label="<%= t("pwa.offline.practice.correct") %>">
  <!-- start / question / result の 3 ビュー -->
</div>

controller(offline_practice_controller.js)の処理フローは次のとおりです。

  1. connect():IndexedDB から問題を読み出し、利用可能件数 / 同期待ち件数を表示
  2. start():Fisher-Yates でシャッフルし、最初の問題を表示
  3. submit():選択肢を arraysEqual でローカル採点 → enqueueAttempt() で IndexedDB に保存 → 結果表示
  4. next():次の問題へ。末尾まで来たらシャッフルし直してループ
  5. sync():同期ボタン押下時に POST /offline_attempts で一括送信 → 成功なら removeAttempts()

同期は、オンラインに戻ったときに溜まった回答データをサーバーへ送る処理です。この POST にも Rails の CSRF 保護は効くので、コードの data-...-csrf-token-value で渡したトークンを X-CSRF-Token ヘッダに乗せて送ります。

4. オフライン同期の実装について

§3 では同期を、windowonline イベントと「同期」ボタンで実装しました。実はオフライン同期には、もう一つ Background Sync API という定番の選択肢があります。Service Worker に sync イベントを登録しておけば、ブラウザがネットワーク復帰を検知して自動で同期を走らせてくれる仕組みです。今回はこれを採用しませんでした。

理由は 2 つです。1 つは iOS Safari が Background Sync API に対応していないこと。このアプリの主要ターゲットは iPhone なので、iOS で動かない仕組みをメイン経路には据えられません。

もう 1 つは UX 設計上の判断です。学習アプリでは 「自分の回答がいつサーバーに届いたか」をユーザが把握できる方が安心感がある。Background Sync で裏側で勝手に同期されるより、「同期待ち N 件」を表示して「同期」ボタンで送る、あるいはオンライン復帰時に自動同期する、という ユーザに見える同期の方が、この用途には合っています。

5. 動作確認

動作確認は Claude Code(Playwright の自動テスト)と iPhone 実機で行いました。確認した項目は次のとおりです。

  • Playwright:コンソールに [offline-sync] saved N questions for sap が出て、IndexedDB の questions ストアに件数分のレコードが入る(ローカル 225 / 本番 423 問)
  • iPhone 実機:オフライン時に「○○ 問が端末に保存済み」バナーが出る ── fetch・保存・オフライン検知が一通り効いている証拠
  • iPhone 実機:オフラインで解く →「未同期 N 件」が増える → オンライン復帰 →「今すぐ同期」でバナーが消える、のフルフロー
  • 同期は成功(200)時だけキューを消す設計なので、「バナーが消えた = attempts に記録済み」と言い切れる

6. ハマったところ・判断の記録

6-1. importmap 未更新で module specifier が解決できない

offline/question_store を import しようとして Failed to resolve module specifier で止まりました。importmap-rails は明示的に pin したモジュールしか解決しません。config/importmap.rbpin_all_from "app/javascript/offline", under: "offline" を足す必要があります。bin/importmap json で実際に pin されているか確認できます。Webpack などのバンドラに慣れていると「ファイルを置けば import できる」と思いがちですが、importmap は違います。

6-2. DB バージョンバンプ時のテストの難しさ

IndexedDB のスキーマを v1v2 に上げたとき、確認すべきは「新規ユーザ(v0 → v2)」と「既存ユーザ(v1 → v2)」の 2 経路です。前者は DevTools → Application → IndexedDB → Delete database でクリーンスタートすれば確認できます。問題は後者で、「一度 v1 のコードでアプリを開いてから、v2 のコードに差し替える」という手順を踏まないと再現できません。本番デプロイでは既存ユーザは全員この経路を通るので、横着せず dev で再現テストをやっておくのが事故防止になります。

7. Phase 3+4 で変わったこと、次フェーズの予告

Phase 1+2 では「オフラインで開ける」だけでしたが、Phase 3+4 を入れて 「オフラインで実際に学習できる」状態になりました。

  • 機内モード・地下鉄でも、IndexedDB に保存済みの問題を解ける
  • 「次の問題へ」が押せる ── サーバー往復がないので、むしろオンラインより軽快
  • オフライン中の回答は端末にキューイングされ、オンライン復帰時に一括同期される
  • 既存のサーバー駆動セッションは一切変更していない ── 2 経路が住み分けて共存

ここまでで「アプリを開けば学習できる」は完成しました。残るのは 「アプリを開くきっかけ」をアプリ側から作ること ── つまり通知です。次の Phase 5(最終回)では、Web Push 通知を実装します。VAPID 鍵の管理、iOS 16.4+ の standalone モード制約、Solid Queue による日次配信、失敗購読の自動破棄まで扱う予定です。続編:Rails 8 で Web Push 通知を実装した話 へ続きます。

Endnote

あとがき

本記事は、Rails 8 の PWA に「実際に使えるオフライン機能」を足すまでの実装記録です。技術的な核は IndexedDB ですが、本当に伝えたかったのは 「既存のサーバー駆動 UI を SPA 化で作り直すのではなく、独立した別経路として足す」という設計判断の方です。オフライン対応は、ともすると「アプリ全体を作り直す」大工事になりがちですが、役割を割り切って住み分けさせれば、既存 UX を一切壊さずに段階的に足せます。

MOOBON では Rails / Web アプリケーションの新規開発・既存案件への機能追加・PWA 化やオフライン対応の設計 を承っています。「既存の Rails アプリにオフライン機能を足したいが、影響範囲を抑えたい」「IndexedDB や Service Worker の設計をレビューしてほしい」といったご相談も歓迎です。info@moobon.jp までお気軽にどうぞ。

FAQ

よくある質問

Qなぜ既存の study_session 経路を SPA 化してオフライン対応しなかったのですか?
A

既存の学習セッション(study_session)は、「次に出すべき問題」をサーバー側で都度計算する設計です。ユーザの正答率や苦手分野を見て出題を最適化するロジックが Rails 側にあり、「次の問題へ」のたびにサーバー往復が前提になっています。これをオフラインで動かすには、出題ロジックごとクライアントに移植する SPA 化レベルのリライトが必要で、影響範囲が大きすぎます。本案件では既存フローを一切触らず、別経路 /exams/:slug/offline_practice を新設しました。オフライン練習は「ランダムにシャッフルして解く軽い復習」と役割を割り切り、本気で解くなら従来のセッション、という住み分けです。既存 UX への影響をゼロにできること、機能として独立して育てられることがメリットです。

Qなぜ idb や Dexie などの IndexedDB ライブラリを使わなかったのですか?
A

IndexedDB は API が古く Promise ベースでないため、ライブラリで包むのが一般的です。ただし本案件で扱うストアは 3 つ(questions / meta / attempts_queue)だけで、必要な操作も「全件 put」「全件 get」「条件付き delete」程度です。openDB と withStore というヘルパーを Promise でラップすると、スキーマ定義込みで 50 行程度に収まりました。importmap-rails 環境では npm 依存を増やすとビルド構成にも影響が出るため、この規模なら自前ラッパーの方が依存を増やさず見通しが良いという判断です。ストア数や操作の複雑さが増えてきたら、その時点で idb への移行を検討します。

QJSON ペイロードに correct_answers(正解)を含めるのはセキュリティ上問題ないですか?
A

オフラインでローカル採点するために correct_answers をペイロードに含めています。「DevTools を開けば正解が見える」状態にはなりますが、これは元々「回答を送信したあとに表示される情報」であり、機密情報ではありません。試験問題そのものの著作的な扱いは別途考慮が要りますが、正解データを隠すことに技術的な意味は薄いと判断しました。逆に、本当に隠すべき情報(他ユーザのデータ、課金情報など)は当然ペイロードに含めません。「オフラインで自己採点する」という機能要件と、「隠しても意味がない情報」という性質が噛み合っているケースです。

QIndexedDB のスキーマアップグレード(v1 → v2)はどうテストしましたか?
A

IndexedDB は onupgradeneeded の中で oldVersion を見て段階的にマイグレーションを書きます(if oldVersion < 1 / if oldVersion < 2 のパターン)。テストで確認すべきは 2 経路で、(1) 新規ユーザ(v0 → v2 を一気に通る)、(2) 既存ユーザ(v1 → v2 の差分だけ通る)。開発中は DevTools → Application → IndexedDB → Delete database で v0 状態を作って (1) を確認し、一度 v1 のコードで開いてから v2 のコードに差し替えて (2) を確認します。本番デプロイでは既存ユーザは全員 (2) の経路を通るので、この再現を dev で必ずやっておくのが事故防止になります。

Qユーザが端末を変えた場合、オフライン中の回答はどうなりますか?
A

オフライン中の回答は、その端末の IndexedDB の attempts_queue ストアにローカル保存されます。オンライン復帰時にサーバーへ一括同期し、成功したらキューから削除されます。同期する前に別端末に乗り換えたり、PWA をアンインストールしたりすると、その未同期分は失われます。これは「オフライン中の回答は端末ローカルな一時データ」と割り切った設計で、同期さえ済めばサーバー側の attempts テーブルに正規データとして残ります。実運用上は「オンラインに戻ったら早めに同期ボタンを押す」「アプリ起動時に自動同期する」で大半をカバーできます。複数端末間のリアルタイム同期が要件になったら、その時点で設計を見直す前提です。

MOOBONISO/IEC 27001 CertificationIT導入補助金 支援事業者
Copyright © 2026 MOOBON, Inc. All Rights Reserved.
適用規格:ISO/IEC 27001:2022
適用範囲:Web 系システム設計支援 / 自社クラウドサービス開発・運用・保守 / 受託システム開発・運用・保守 / サーバ構築・運用・保守