# Durable Objects 運用 — Pilot: RateLimiterDO

監査 P1 (A6 / 2026-04-26 / Phase 30) で導入した最初の DO。Cloudflare Rate Limiting binding (`RATE_LIMIT_USER` 等) では難しい (user × resource) 複合 scope の sliding window 制御を担う。

## 全体構成

```
Worker fetch handler
  └─ checkRateLimitDO(env, { scope, keys, limit, windowSeconds })
        │
        └─ DurableObjectNamespace.idFromName(`${scope}|${k1}=${v1}|${k2}=${v2}`)
              │
              └─ RateLimiterDO instance (state: { hits[], limit, windowSeconds })
                    └─ POST /check → 429 if limit exceeded, 200 otherwise
                       (X-RateLimit-Remaining / X-RateLimit-Reset ヘッダ付与)
```

- インスタンス名は scope + keys のタプルから決定論的に組立。同 scope のリクエストは必ず同 instance に集約される (atomic counter 性が確保される)。
- `state.hits` は memory + storage に保持。コールドスタート復元あり。

## binding 定義

[wrangler.toml](../../api/wrangler.toml) と 3 split file (`wrangler.{admin,marketing,public}.toml`) すべてに:

```toml
[[env.dev.durable_objects.bindings]]
name = "RATE_LIMIT_DO"
class_name = "RateLimiterDO"

[[env.dev.migrations]]
tag = "v1-rate-limiter-do"
new_sqlite_classes = ["RateLimiterDO"]
```

`new_sqlite_classes` は新世代 SQLite-backed DO (Cloudflare 推奨)。古い `new_classes` ではなくこちらを使う。

DO クラスはメインモジュールから `export` する必要がある。各 entrypoint:
- [parky/api/src/index.ts](../../api/src/index.ts)
- [parky/api/src/index-admin.ts](../../api/src/index-admin.ts)
- [parky/api/src/index-marketing.ts](../../api/src/index-marketing.ts)
- [parky/api/src/index-public.ts](../../api/src/index-public.ts)

すべて末尾に `export { RateLimiterDO } from "./durable-objects/rate-limiter";` を持つ。

## helper

[parky/api/src/lib/rate-limit-do.ts](../../api/src/lib/rate-limit-do.ts)

```ts
import { checkRateLimitDO } from "../../lib/rate-limit-do";

const result = await checkRateLimitDO(c.env, {
  scope: "notif.send",
  keys: { user_id: userId, target_lot: lotId },
  limit: 3,
  windowSeconds: 30,
});
if (!result.allowed) {
  throw rateLimited("通知送信が短時間に集中しています");
}
```

binding `RATE_LIMIT_DO` 未設定 → fail-open (always allow + `fallback: true`)。dev / preview の救済用。

## 既存 RATE_LIMIT_USER との使い分け

| 観点 | RATE_LIMIT_USER (binding) | RateLimiterDO (pilot) |
|---|---|---|
| 設定 | wrangler `[[unsafe.bindings]]` | DO binding + migration |
| scope 次元 | 単一 (key 文字列) | 複合 (scope + multiple keys) |
| Window 制御 | fixed (10/60s) | sliding (動的) |
| 動的 limit | 静的 | リクエストパラメータで上書き可 |
| コスト | 無料 | DO 課金 (1M req/月 = $0.20) |
| 推奨用途 | AI 検索の単一ユーザー上限 | per-user × per-resource の細粒度 |

順次拡張先: 通知送信の per-user×per-target / コメント投稿の per-user×per-thread / 設定変更の per-user×per-resource など。

## デプロイ

DO の `migrations` は Worker deploy 時に Cloudflare 側に登録される。初回 deploy で `RateLimiterDO` クラスが認識される。

```bash
wrangler deploy --env dev
# → "Triggered new migration: v1-rate-limiter-do"
```

prod 投入時の注意:
- 既存 DO に対する破壊的変更 (rename / class 削除) は新しい migration tag (例 `v2-...`) で `renamed_classes` / `deleted_classes` を指定。
- 同じ tag の重複適用はエラー。

## トラブルシュート

### DO instance が反応しない
- `wrangler tail --env dev` でログ確認
- migration tag が dev / prod で揃っていない場合 deploy がエラー終了

### hits が想定と違う
- インスタンス名が違う可能性 → caller 側 `keys` を確認 (`Object.entries().sort()` で deterministic)
- 異なる instance に分散していないか peek: `RATE_LIMIT_DO.idFromName(name)` の name を直接出力して比較

### コスト
- DO の writeDataPoint と異なり、storage put 含むので実 transaction がカウントされる
- 無料 tier 1M / 月。Parky の現状トラフィックなら余裕

## ロードマップ

| Wave | 用途 | scope |
|---|---|---|
| W1 (今回) | 通知送信 burst 防止 | notif.send × user × target |
| W2 | コメント投稿 spam 防止 | comment.post × user × thread |
| W3 | Live Presence (WebSocket) | presence × room |
| W4 | Idempotency cache (KV alternative) | idempotency × method × key |
