Room アクティビティログを永続化する

CloudflareBeginner
オンラインで実践に進む

はじめに

実行中の JavaScript オブジェクトは、クラスプロパティに値を保持できます。しかし、ランタイムが再起動・クラッシュしたり、非アクティブなオブジェクトがメモリから削除されたりすると、その値は失われます。アクティビティログでは、このような状態は許容できません。Room のメンバーは、アプリケーションコードを再デプロイした後も、昨日のイベントが表示されることを期待します。

この実験では、検証済みの Room 名ごとに 1 つの Durable Object を選択します。そのオブジェクトは、アクティビティイベントを格納する専用の SQLite データベースを所有します。フロントドアの Worker は RPC 経由でオブジェクトを呼び出すため、クライアントがストレージに直接アクセスすることはありません。ローカルランタイムを停止して再起動し、その後クラウド Worker を再デプロイして新しい接続を開きます。どちらの場合も、以前に書き込んだ行が利用できることを確認します。2 つ目の Room を使って、ストレージが名前空間全体ではなく、個々のオブジェクト ID に属することも確認します。

ここでは、次の 2 種類の状態も比較します。

  • メモリ内状態は JavaScript のプロパティに保存され、一時的なキャッシュとしてのみ役立ちます。
  • 永続状態はリクエストが完了する前にオブジェクトのストレージへ書き込まれ、ランタイムが置き換えられても保持されます。

このコースに直接進む前に、LabEx を Cloudflare アカウントに接続するを完了してください。 新しい VM ごとに、独自の Wrangler 認証が必要です。前の実験で、Worker のリクエストハンドラー、Durable Object の名前、バインディング、RPC についてすでに理解していることを前提とします。基本的な SQL のキーと順序付きクエリについては、登場する箇所で説明します。

現在、Cloudflare は Workers Free で SQLite-backed Durable Objects をサポートしています。この実験では、破棄可能なクラス名前空間を 1 つ、名前付きオブジェクトを少数、上限付きのリクエストだけを作成します。セットアップでは、/home/labex/project/room-activity-log に Node.js 22.22.0 とプロジェクトローカルの Wrangler 4.132.0 をインストールします。Cloudflare の認証、名前空間の作成、Worker のデプロイ、学習者のアクティビティレコードの書き込みは行いません。

VM を認証して Room 名前空間を設定する

このステップでは、新しい VM を認証し、学習用アカウントを選択して、SQLite-backed Durable Object クラスを 1 つ定義します。Dashboard へのログインと VM の認証が別になっているのは、VM がブラウザーのセッションにアクセスできないためです。

用意されたプロジェクトに移動し、固定された Wrangler のバージョンを確認します。

cd /home/labex/project/room-activity-log
npx wrangler --version

4.132.0 と表示されることを確認します。デバイス認証を開始します。

npx wrangler login --device --browser=false

表示された URL をブラウザーで開き、短いコードを入力して、選択されたアカウントと権限を確認してから認証を許可します。ブラウザーと Wrangler の両方で成功が報告されてから、ターミナルに戻ってください。実験環境にパスワードやトークンを貼り付けないでください。

構造化された ID 情報を読み取り、使用するアカウント ID を非表示のまま選択します。

WHOAMI="$(npx wrangler whoami --json)"
printf '%s\n' "$WHOAMI" | jq '{loggedIn, authType, accounts: [.accounts[] | {name}]}'
ACCOUNT_ID="$(printf '%s\n' "$WHOAMI" | jq -r '.accounts[] | select(.name == "LabEx Learning") | .id')"
test -n "$ACCOUNT_ID"

最初の jq 式は、安全な ID 情報だけを表示します。2 つ目の式は、アカウント ID を表示せずにシェル変数へ保存します。専用の学習用アカウントの表示名が異なる場合は、確認した名前に置き換えてください。

一意の Worker 名を生成します。

RUN="labex-c10-o02-$(openssl rand -hex 6)"
printf '%s\n' "$RUN"

設定ファイルを作成します。引用符で囲んでいない JSON の区切り文字は $RUN$ACCOUNT_ID を展開します。\$schema によって、JSON のキーはそのまま保持されます。

cat > wrangler.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/index.js",
  "compatibility_date": "2026-09-18",
  "workers_dev": true,
  "preview_urls": false,
  "observability": {
    "enabled": true,
    "head_sampling_rate": 1
  },
  "durable_objects": {
    "bindings": [
      { "name": "ROOMS", "class_name": "RoomActivity" }
    ]
  },
  "exports": {
    "RoomActivity": { "type": "durable-object", "storage": "sqlite" }
  }
}
JSON

ROOMS は、Worker が名前空間を参照するためのハンドルです。exports エントリは、すべての RoomActivity オブジェクトがそれぞれ独自の SQLite データベースを使用することを Cloudflare に伝えます。このファイルを作成しただけでは、クラウドリソースは作成されません。デプロイは後のステップで行います。

Room イベントを SQLite に保存する

このステップでは、Room が所有するテーブルと、2 つの RPC メソッドを実装します。1 つはイベントを追加し、もう 1 つは順序付きの履歴を返します。

アクティビティイベントには、変更されないテキストキー、短いタイプ、人間が読める詳細情報、サーバー時刻が含まれます。PRIMARY KEY 制約により、1 つの Room 内で同じイベント ID を持つ行が 2 つ作成されることを防ぎます。AUTOINCREMENT は単調増加する sequence を割り当てます。これにより、同じ時刻のイベントがあっても、時刻に依存せず読み取りクエリで挿入順を維持できます。

Worker のエントリポイントを作成します。

cat > src/index.js <<'JS'
import { DurableObject } from "cloudflare:workers";

export class RoomActivity extends DurableObject {
  constructor(ctx, env) {
    super(ctx, env);
    ctx.blockConcurrencyWhile(async () => {
      this.ctx.storage.sql.exec(`
        CREATE TABLE IF NOT EXISTS activity_events (
          sequence INTEGER PRIMARY KEY AUTOINCREMENT,
          event_id TEXT NOT NULL UNIQUE,
          event_type TEXT NOT NULL,
          detail TEXT NOT NULL,
          created_at INTEGER NOT NULL
        )
      `);
    });
  }

  appendEvent(event) {
    const createdAt = Date.now();
    return this.ctx.storage.sql.exec(
      `INSERT INTO activity_events (event_id, event_type, detail, created_at)
       VALUES (?, ?, ?, ?)
       RETURNING sequence, event_id AS eventId, event_type AS type, detail, created_at AS createdAt`,
      event.eventId,
      event.type,
      event.detail,
      createdAt
    ).one();
  }

  listEvents() {
    return this.ctx.storage.sql.exec(
      `SELECT sequence, event_id AS eventId, event_type AS type, detail, created_at AS createdAt
       FROM activity_events
       ORDER BY sequence`
    ).toArray();
  }
}

function json(data, status = 200) {
  return Response.json(data, { status });
}

function roomRoute(pathname) {
  const match = pathname.match(/^\/rooms\/([^/]+)\/events$/);
  if (!match) return { error: "not_found", status: 404 };
  let room;
  try {
    room = decodeURIComponent(match[1]);
  } catch {
    return { error: "invalid_room_name", status: 400 };
  }
  if (!/^[a-z][a-z0-9-]{0,31}$/.test(room)) {
    return { error: "invalid_room_name", status: 400 };
  }
  return { room };
}

function validEvent(value) {
  return value &&
    /^[a-z][a-z0-9-]{2,31}$/.test(value.eventId) &&
    /^[a-z][a-z0-9_]{2,31}$/.test(value.type) &&
    typeof value.detail === "string" &&
    value.detail.length >= 1 && value.detail.length <= 160;
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (request.method === "GET" && url.pathname === "/health") {
      return json({ status: "ok" });
    }

    const parsed = roomRoute(url.pathname);
    if (parsed.error) return json({ error: parsed.error }, parsed.status);
    if (request.method !== "GET" && request.method !== "POST") {
      return json({ error: "method_not_allowed" }, 405);
    }

    const room = parsed.room;
    let body;
    if (request.method === "POST") {
      try {
        body = await request.json();
      } catch {
        return json({ error: "invalid_json" }, 400);
      }
      if (!validEvent(body)) return json({ error: "invalid_event" }, 400);
    }

    const stub = env.ROOMS.getByName(room);
    try {
      if (request.method === "POST") {
        const event = await stub.appendEvent(body);
        console.log(JSON.stringify({ event: "room_activity_appended", room, eventId: event.eventId, sequence: event.sequence }));
        return json({ room, event }, 201);
      }
      const events = await stub.listEvents();
      console.log(JSON.stringify({ event: "room_activity_listed", room, count: events.length }));
      return json({ room, events });
    } catch (error) {
      if (String(error).includes("UNIQUE constraint failed")) {
        return json({ error: "duplicate_event_id" }, 409);
      }
      throw error;
    }
  }
};
JS

blockConcurrencyWhile() はスキーマの作成にだけ使用します。テーブルが存在するまでリクエストを遅延させますが、通常のトラフィックや外部 I/O を囲むものではありません。重要なアプリケーション状態をクラスプロパティだけに保存してはいけません。appendEvent() は、返す前に行を SQLite へ書き込みます。

用意された決定的な HTTP ルーティングテストと、実際の Wrangler バンドル確認を実行します。

NODE_NO_WARNINGS=1 node --experimental-loader ./test/cloudflare-loader.mjs --test test/worker.test.mjs
npx wrangler deploy --dry-run

2 件のテストが成功し、dry run も成功することを確認します。これらの確認では、リモートへのデプロイは行われません。

再起動後もローカルのデータが保持されることを確認する

このステップでは、planning Room に 2 つのイベントを書き込み、ローカルの Workers ランタイムを完全に停止します。その後、同じローカルストレージディレクトリを使って新しいランタイムを起動し、再び行を読み取ります。

通常、Wrangler はローカルバインディングのデータを .wrangler/state に保存します。この実験では、永続性の境界を明確にするため、明示的に .labex/local-state ディレクトリを使用します。このディレクトリはローカル開発データのみを表し、Cloudflare のストレージとは別です。

最初のローカルランタイムを起動します。

npx wrangler dev --port 8787 --persist-to .labex/local-state > .labex/dev.log 2>&1 &
echo $! > .labex/dev.pid
for attempt in $(seq 1 30); do
  curl --silent --fail http://127.0.0.1:8787/health && break
  sleep 1
done

planning に 2 つのイベントを追加します。--data は JSON 本文を送信し、content-type ヘッダーは Worker に本文の解釈方法を伝えます。

curl --silent --request POST http://127.0.0.1:8787/rooms/planning/events \
  --header 'content-type: application/json' \
  --data '{"eventId":"evt-opening","type":"room_opened","detail":"Planning room opened"}' | jq
curl --silent --request POST http://127.0.0.1:8787/rooms/planning/events \
  --header 'content-type: application/json' \
  --data '{"eventId":"evt-notes","type":"note_added","detail":"Release notes drafted"}' | jq

Room を読み取り、12 のシーケンスを確認します。

curl --silent http://127.0.0.1:8787/rooms/planning/events | jq

次に、そのランタイムを終了し、プロセスが終了するまで待ちます。

kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true

同じ永続化ディレクトリを使って、新しいランタイムプロセスを起動します。

npx wrangler dev --port 8787 --persist-to .labex/local-state > .labex/dev-restarted.log 2>&1 &
echo $! > .labex/dev.pid
for attempt in $(seq 1 30); do
  curl --silent --fail http://127.0.0.1:8787/health && break
  sleep 1
done

もう一度 planning を読み取り、続けて、まだイベントを受け取っていない別の Room を読み取ります。

curl --silent http://127.0.0.1:8787/rooms/planning/events | jq
curl --silent http://127.0.0.1:8787/rooms/support/events | jq

新しいランタイムは、2 つの planning イベントを順番どおりに返します。一方、support は空の events 配列を返します。再起動によって JavaScript のクラスインスタンスはすべて削除されましたが、SQLite の行は削除されていません。2 つ目の Room が空であることから、各名前付きオブジェクトが専用のストレージを所有していることも確認できます。

デプロイしてクラウドにアクティビティを書き込む

このステップでは、ローカルプロセスを停止し、クラス名前空間をデプロイして、小さなクラウドアクティビティ履歴を書き込みます。

再起動したローカルランタイムを停止し、後続のリクエストがクラウドからのレスポンスと混同されないようにします。

kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true

通常のターミナル出力を保存しながら Worker をデプロイします。tee /dev/tty によって出力を画面に表示したまま、$(...) でシェル変数にも取り込めます。

DEPLOY_OUTPUT="$(npx wrangler deploy 2>&1 | tee /dev/tty)"

最初のデプロイでは、RoomActivity の export を調整し、SQLite-backed namespace を作成します。他の学習者が同じサブドメインを使っているとは限らないため、表示された workers.dev URL を前提にせず抽出します。

APP_URL="$(printf '%s\n' "$DEPLOY_OUTPUT" | grep -Eo 'https://[a-z0-9.-]+\.workers\.dev' | tail -1)"
test -n "$APP_URL"
printf '%s\n' "$APP_URL"

grep -Eo は一致した URL のテキストだけを表示し、tail -1 は、ほかの情報行にリンクが含まれている場合でも最後のアドレスを選択します。

デプロイが成功しても、Worker コードと新しい Durable Object namespace がすべてのエッジから到達可能になるまで数秒かかる場合があります。まだ空の support オブジェクトから期待どおりの JSON が返るまで待ってから、書き込みを行います。

for attempt in $(seq 1 30); do
  if curl --silent --fail "$APP_URL/rooms/support/events" |
    jq -e '.room == "support" and .events == []' >/dev/null; then
    break
  fi
  sleep 1
done
curl --silent --fail "$APP_URL/rooms/support/events" |
  jq -e '.room == "support" and .events == []'
sleep 5

最後の読み取りによって準備完了を明確に確認できます。Durable Object のルートがまだ有効な JSON を返さない場合、実験はここで停止し、エッジのエラーページを後続のコマンドへ渡しません。短い待機時間によって、新しく調整された namespace がエッジ全体へ伝播している途中で、2 つ目の名前付きオブジェクトを作成することも避けます。

同じ論理的な planning イベント 2 件をクラウドストレージに書き込みます。ローカルとリモートの Durable Object データベースは意図的に別の環境なので、クラウドの Room は空の状態から始まります。

curl --silent --request POST "$APP_URL/rooms/planning/events" \
  --header 'content-type: application/json' \
  --data '{"eventId":"evt-opening","type":"room_opened","detail":"Planning room opened"}' | jq
curl --silent --request POST "$APP_URL/rooms/planning/events" \
  --header 'content-type: application/json' \
  --data '{"eventId":"evt-notes","type":"note_added","detail":"Release notes drafted"}' | jq

planning と、まだ操作していない support Room の両方を読み取ります。

curl --silent "$APP_URL/rooms/planning/events" | jq
curl --silent "$APP_URL/rooms/support/events" | jq

クラウドの planning オブジェクトには 2 行が含まれ、support は空のままです。これにより、デプロイの置き換えをテストする前に、クラウド上の ID と分離が確認できます。

再デプロイして永続状態を確認する

このステップでは、同じ Worker 名とクラス宣言を使って再デプロイします。その後、新しい HTTP 接続を通じて既存の行を読み取り、ランタイムで確認した結果を Dashboard の情報と関連付けます。

変更していないアプリケーションをもう一度デプロイします。

npx wrangler deploy

コードのデプロイによって実行中の Durable Object インスタンスが置き換えられることがあり、その場合はクラスプロパティが消去されます。ただし、同じ RoomActivity export が引き続き宣言されている限り、namespace は置き換えられません。新しいリクエストを開き、planning の履歴を読み取ります。

curl --silent "$APP_URL/rooms/planning/events" | jq

evt-openingevt-notes の行が、シーケンス順に表示される必要があります。これが、一時的なメモリ内配列と SQLite-backed の永続状態との重要な違いです。

Cloudflare Dashboard を開き、同じアカウントを選択します。Workers & Pages に移動し、正確な labex-c10-o02-... Worker を見つけます。その Durable Object バインディングの名前が ROOMS で、対象が RoomActivity であることを確認します。次に Durable Objects を開き、その namespace を選択して Overview を確認します。namespace 名はデプロイされた Worker とクラスを識別し、Storage: SQLwrangler.jsonc で選択されたバックエンドを確認します。

デプロイされた Worker のバインディングが ROOMS を RoomActivity Durable Object namespace に接続している

スクリーンショットには検証対象の実行結果が表示されています。生成されたサフィックスは異なりますが、バインディングの種類、名前、対象クラスは設定と一致している必要があります。

RoomActivity namespace の概要に SQL ストレージバックエンドが表示されている

Dashboard が namespace のメトリクスを集計するまで時間がかかる場合があるため、2 行が保持されたことを示す正式な証拠は HTTP レスポンスです。Overview は状態を把握するための確認ポイントであり、ランタイムの読み取り結果に代わるものではありません。

namespace の Logs ビューを開きます。成功した RoomActivity.jsrpc の行は、Cloudflare が RPC 経由でクラスを呼び出したことを示します。同じオブジェクト ID が繰り返し表示される場合、同じオブジェクトへの呼び出しが繰り返されています。ほかの ID は別の Room と、検証処理で実行ごとに生成される Room に由来します。これらの ID は Cloudflare が生成した例であり、コピーして Room 名として使うものではありません。ログは呼び出しを証明し、順序付きの HTTP レスポンスは保存されたアクティビティの内容を証明します。

成功した RoomActivity RPC 呼び出しが namespace のログに Durable Object ID とともに表示されている

独立したデプロイ済みチェックをもう一度実行します。これはバインディングと所有する namespace を検証し、保持された planning の行を読み取り、空の support Room を確認し、検証専用の一意な Room を作成します。

python3 .labex/verify.py deployed

Namespace を削除して VM のアクセス権を取り消す

このステップでは、Durable Object namespace とそこにあるすべての Room データベースを削除してから、残りの Worker を削除し、ログアウトします。

Worker だけを削除しても、Durable Object クラスが明示的に廃止されるわけではありません。宣言型のライフサイクルでは、deleted tombstone を使用します。これはこのクラス namespace を完全に削除し、Trash もありません。そのため、続行する前に $RUNlabex-c10-o02- で始まっていることを確認してください。

状態を持たないクリーンアップ用エントリポイントを作成します。

cat > src/cleanup.js <<'JS'
export default {
  fetch() {
    return Response.json({ status: "cleanup" }, { status: 410 });
  }
};
JS

まったく同じ Worker とアカウントを対象とするクリーンアップ設定を作成します。この設定ではバインディングを削除し、RoomActivity だけを削除済みとしてマークします。

ACCOUNT_ID="$(node -e 'console.log(JSON.parse(require("fs").readFileSync("wrangler.jsonc", "utf8")).account_id)')"
cat > wrangler.cleanup.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/cleanup.js",
  "compatibility_date": "2026-09-18",
  "workers_dev": true,
  "preview_urls": false,
  "exports": {
    "RoomActivity": { "type": "durable-object", "state": "deleted" }
  }
}
JSON

tombstone をデプロイし、調整結果を確認します。

npx wrangler deploy --config wrangler.cleanup.jsonc

RoomActivity が削除されたと報告されるはずです。これにより、planningsupport、検証処理用の一時 Room、およびこの実験で所有していた namespace 内のすべての SQLite 行が削除されます。残りの状態を持たない Worker を削除します。

npx wrangler delete --config wrangler.cleanup.jsonc

生成された正確な Worker だけが対象になっていることを確認します。認証がまだ有効なうちに、認証済みの存在確認を実行します。

python3 .labex/verify.py deleted

PASS: deleted と表示された後でのみ、ログアウトして構造化されたログアウト状態を確認します。

npx wrangler logout
npx wrangler whoami --json

最後の出力では loggedIn: false と報告される必要があります。ネットワーク障害は、リソースの削除やログアウトの証拠にはなりません。

まとめ

安定した Room 名ごとに 1 つの Durable Object と専用の SQLite データベースを選択する Room アクティビティサービスを構築しました。キー付きで順序を保持するイベントテーブルを作成し、RPC 経由で追加と一覧取得の操作を公開しました。また、オブジェクトを選択する前にリクエストを検証し、2 つ目の Room が別の Room の履歴を引き継がないことを確認しました。

さらに、ローカルランタイムの再起動後とクラウドへの再デプロイ後に同じ行を読み取ることで、一時的な JavaScript メモリと永続ストレージの違いを確認しました。最後に、Dashboard でバインディング、namespace、保存された行、ログを確認し、VM の認証を取り消す前に、対象の namespace と Worker を削除しました。