# Supabase Edge Functions 実践ガイド：@supabase/server・背景タスク・制約の中で本番運用する

> Supabase Edge Functionsを本番で使い切るための実装ガイド。CPU 2秒・メモリ256MB・実行時間150/400秒という制約から「何を載せて何を載せないか」を判断し、@supabase/serverのwithSupabaseで認証とRLSスコープを宣言的に扱い、Webhookを冪等に受け、EdgeRuntime.waitUntilで重い処理を背に回す。Hono routing・シークレット・Deno.testでのテスト・ログ・CI/CDまで、公式ドキュメント準拠の実コードで解説します。

- 公開日: 2026-08-16
- 著者: 友田 陽大
- タグ: Supabase, TypeScript, アーキテクチャ設計, 信頼性, セキュリティ, コスト最適化
- URL: https://tomodahinata.com/blog/supabase-edge-functions-production-guide-with-supabase-background-tasks-limits
- カテゴリ: データベース・RLS
- 総合ガイド: https://tomodahinata.com/blog/supabase-production-guide-nextjs-rls-realtime-edge-functions

## 要点

- 採否はCPU時間で決まる。リクエストあたりCPU 2秒・メモリ256MB・実行時間はFree 150秒/有料400秒。重い計算やバンドル済み巨大依存は載せず、I/O待ちが主体の処理を載せる
- 認証は@supabase/serverのwithSupabaseで宣言する。auth: 'user' なら ctx.supabase が呼び出し元のRLSにスコープされ、ctx.supabaseAdmin がRLSバイパス。jose もJWKS設定も自前で書かない
- Webhookは『検証→冪等キーで記録→即座に2xx→重い処理はEdgeRuntime.waitUntil』の順に組む。レスポンスを返してから処理を続けられるのがEdge Functionsの強み
- テストは純粋関数を分離してDeno.testでミリ秒で回す。globalThis.fetchをモックすれば、本番コードのまま経路全体を検証できる
- ログは1メッセージ10,000文字・10秒あたり100イベントが上限。printfデバッグではなく構造化ログにし、PIIを載せない

---

Edge Functionsは、Supabaseで最も**過小評価されている**機能であり、同時に最も**誤用されている**機能でもあります。

過小評価される理由は「フロントからDBを直接叩けるなら要らないのでは」という誤解。誤用される理由は、**制約を知らずに重い処理を載せてしまう**ことです。CPU実行時間は1リクエストあたり2秒。この1行を知っているかどうかで、設計の成否が分かれます。

この記事は、Supabase Edge Functionsを**本番で使い切る**ための実装ガイドです。公式ドキュメント（2026-08-16時点）に忠実に、採否の判断基準、`@supabase/server` の `withSupabase` による認証の宣言的な扱い、Webhookを冪等に受ける実装、レスポンス後の背景タスク、テスト・ログ・CI/CDまでを、そのまま使えるコードで扱います。

---

## 1. まず制約を見る：何を載せて、何を載せないか

Edge Functionsは「Deno互換ランタイム、TypeScriptファースト」の実行環境です。公式のリミットはこうです。

| 項目 | 値 |
| --- | --- |
| **CPU実行時間** | **リクエストあたり 2秒**（実際にCPUを使った時間） |
| 実行時間（ウォールクロック） | Free 150秒 / 有料 400秒 |
| メモリ | 256MB |
| リクエストのアイドルタイムアウト | 150秒（応答が無ければ 504 Gateway Timeout） |
| 関数サイズ | CLIバンドル 20MB / サーバー側バンドル 5MB |
| 関数数 | Free 100 / Pro 500 / Team 1,000 / Enterprise 無制限 |
| ログ1件の長さ | 最大 10,000文字 |
| ログ件数 | 10秒あたり 100イベント |
| シークレット | 1プロジェクト100個、名前256文字、サイズ48KiB |
| 一時ストレージ（`/tmp`） | Free 256MB / 有料 512MB |

**「CPU 2秒」と「ウォールクロック150〜400秒」が別物である**ことが、この機能の設計の鍵です。外部APIの応答を30秒待っても、待っている間CPUは動いていないので2秒は消費しません。

したがって判断はこうなります。

| 向いている | 向いていない |
| --- | --- |
| Webhookの受信（Stripe、GitHub、決済、CRM） | 画像・動画のエンコード／変換 |
| 外部APIの呼び出しと整形（BFF的な中継） | 大規模なデータ集計・レポート生成 |
| LLM応答のストリーミング中継 | 重い暗号処理・機械学習推論 |
| サーバー限定の秘密鍵を使う処理 | 巨大な依存を含むバンドル（サイズ上限） |
| Cronからの定期ジョブ（軽量なもの） | 長時間のバッチ（DB側のCron/Queuesへ） |
| RLSでは表現しきれない業務ルールの適用 | 常駐が必要なプロセス |

重い処理をどうしても実行する必要があるなら、Edge Functionsは**受け付けて記録し、実行はキューに委譲する**役割に徹します（第6章）。

### 実行される場所

リクエストはグローバルAPIゲートウェイが受け、IPアドレスから地理的位置を判定して**最も近いエッジロケーション**にルーティングします（例：アムステルダムからのリクエストをフランクフルトへ）。各リクエストは新しいV8アイソレートで実行されます。公式は「コンパクトなESZip形式と最小限のDenoランタイムのオーバーヘッドにより、初回実行でもミリ秒単位で高速」としています。

ただし、**データベースが遠ければ全体は速くなりません**。ユーザーの近くで実行されても、DBへの往復が地球を半周するなら意味がない。DBアクセスが主体の関数は、この点を必ず測ってから配置を判断してください。

---

## 2. 最初の1本と `withSupabase`

2026年の標準は `@supabase/server` パッケージの `withSupabase` ラッパーです。公式ブログの説明はこうです。

> 認証の検証、クライアントのセットアップ、リクエストコンテキスト、そしてサーバーサイドの定型処理を代わりに引き受ける新しいパッケージ。Edge Functions、Vercel Functions、Cloudflare Workers、Hono、Bun で動作する。

作成からデプロイまではCLIで完結します。

```bash
supabase functions new hello-world     # supabase/functions/hello-world/index.ts が生成される
supabase start                          # ローカルスタックを起動
supabase functions serve hello-world    # ローカルで実行
supabase functions deploy hello-world   # デプロイ
```

生成されるテンプレートはこうです。

```ts
export default {
  fetch: withSupabase({ auth: ['publishable', 'secret'] }, async (req, ctx) => {
    const { name } = await req.json()

    return Response.json({
      message: `Hello ${name}!`,
    })
  }),
}
```

ローカル呼び出しは次の通りです。

```bash
curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/hello-world' \
  --header 'apiKey: <SUPABASE_PUBLISHABLE_KEY>' \
  --data '{"name":"Functions"}'
```

### 認証モードを「宣言」する

`withSupabase` の中心は `auth` オプションです。これは設定ではなく**この関数を誰が呼べるかの宣言**です。

```ts
// 認証済みユーザーのみ（既定）
withSupabase({ auth: 'user' }, handler)

// 認証不要。Webhookやヘルスチェック向け
withSupabase({ auth: 'none' }, handler)

// サーバー間通信（シークレットキー）
withSupabase({ auth: 'secret' }, handler)

// publishable キー
withSupabase({ auth: 'publishable' }, handler)

// ユーザーJWT または シークレットキーのどちらでも受ける
withSupabase({ auth: ['user', 'secret'] }, handler)
```

さらに**特定のキーだけを受け付ける**指定ができます。`auth: 'secret:automations'` と書けば、ダッシュボードの Settings > API keys で "automations" と名付けたシークレットキーだけが通ります（`publishable:<name>` も同様）。

これは地味に見えて強力です。「バッチ用の関数は、バッチ用のキーでしか呼べない」を**コードで表現**でき、キーが漏れたときの影響範囲が1本のキーの失効で閉じます。

### ctx が渡してくれるもの

```ts
interface SupabaseContext {
  supabase: SupabaseClient      // ユーザーにスコープされる。RLSを尊重
  supabaseAdmin: SupabaseClient // 管理クライアント。RLSをバイパス
  userClaims: UserIdentity | null
  jwtClaims: JWTClaims | null
  authMode: AuthMode
}
```

```ts
import { withSupabase } from 'npm:@supabase/server@^1'

export default {
  fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => {
    // ctx.supabase は呼び出したユーザーのRLSポリシーに自動的にスコープされる
    const { data, error } = await ctx.supabase.from('todos').select()
    if (error) return Response.json({ error: error.message }, { status: 500 })
    return Response.json({ data, email: ctx.userClaims?.email })
  }),
}
```

**`ctx.supabase` と `ctx.supabaseAdmin` の使い分けが、この機能のセキュリティ設計そのもの**です。

- **既定は `ctx.supabase`。** RLSが効くので、認可の誤りがあってもデータは漏れません。
- **`ctx.supabaseAdmin` はRLSを完全にバイパスします。** 使ってよいのは「RLSでは表現できない、正当にユーザーを跨ぐ処理」だけ（例：管理者向けの集計、他ユーザーへの通知作成）。使う行にはコメントで理由を書き、レビューで必ず問う対象にしてください。

`supabaseAdmin` を「エラーが出たから」で使い始めた瞬間、RLSは飾りになります。これは実際に監査でよく見つかるアンチパターンです（[service_role キーの扱い](/blog/supabase-anon-key-service-role-key-exposure-guide)も参照）。

### verify_jwt との関係

プラットフォーム側のJWT検証は `config.toml` で制御します。

```toml
[functions.hello-world]
verify_jwt = false
```

- **ユーザー向けの関数**：`verify_jwt = true`（既定）のままにし、`auth: 'user'` を使う。プラットフォームがハンドラ実行前にJWTを検証します
- **Webhook受信**：`verify_jwt = false` にし、`auth: 'none'` + **署名検証を自分で書く**（第5章）
- **サーバー間**：`verify_jwt = false` + `auth: 'secret'`

> 第4章の署名鍵ローテーションを予定しているなら、`Verify JWT` 設定を使っている関数を先に洗い出してください。公式は「Verify JWT 設定を使っているEdge Functionsがある場合、ローテーションを続けるとアプリが壊れる可能性がある」と警告しています（[Supabase Auth 実装ガイド](/blog/supabase-auth-production-implementation-guide-flows-jwt-signing-keys-sessions-mfa)）。

---

## 3. ルーティング：関数は増やさず、束ねる

関数を機能ごとに10本作るのは、多くの場合悪手です。公式も「複数のアクションを1つの関数にまとめることで、コールドスタートを減らし、1つのインスタンスを複数エンドポイントで温かく保てる」としています。関数数の上限（Free 100）にも効きます。

Honoが最も相性の良い選択です。

```ts
import { Hono } from 'jsr:@hono/hono@^4'

const app = new Hono()

app.get('/hello-world', (c) => {
  return c.json({ message: 'Hello World!' })
})

app.post('/hello-world', async (c) => {
  const { name } = await c.req.json()
  return c.json({ message: `Hello ${name}!` })
})

export default { fetch: app.fetch }
```

**必ず踏む落とし穴**が1つあります。公式の表現では「Edge Functions内では、パスは常に関数名で前置されなければならない」。つまりルートは `/functions/v1/<関数名>/<パス>` になり、Honoに登録するパスにも関数名を含めます。上の例で `app.get('/hello-world', ...)` となっているのはそのためです。ここを `/` にすると404になります。

Express（`npm:express@^5`）やOakも使えますが、Edge環境向けに軽いHonoか、依存を足したくないなら標準のURL Pattern APIで十分です。

---

## 4. シークレットと環境変数

Edge Functionsには既定でいくつかの環境変数が注入されます。

| 変数 | 内容 |
| --- | --- |
| `SUPABASE_URL` | プロジェクトのAPIゲートウェイ |
| `SUPABASE_DB_URL` | Postgresデータベースの URL |
| `SUPABASE_ANON_KEY` | `anon` キー |
| `SUPABASE_SERVICE_ROLE_KEY` | `service_role` キー |
| `SUPABASE_PUBLISHABLE_KEYS` / `SUPABASE_SECRET_KEYS` | APIキーのJSON辞書 |

読み出しはDenoの標準APIです。

```ts
const stripeSecret = Deno.env.get('STRIPE_SECRET_KEY')
if (!stripeSecret) throw new Error('STRIPE_SECRET_KEY is not configured')
```

**起動時に存在を確認する**のが実務上の勘所です。未設定のシークレットは `undefined` として静かに流れ、外部APIから「認証エラー」が返って初めて気づく——という時間の無駄を防げます。

設定はこうです。

```bash
# ローカル: supabase/functions/.env（.gitignore へ！）
supabase functions serve --env-file .env.local

# 本番
supabase secrets set --env-file .env
supabase secrets set STRIPE_SECRET_KEY=sk_live_...
supabase secrets list
```

公式の警告をそのまま置きます。「**`.env` ファイルを絶対にGitにコミットしないこと**」。そして `SUPABASE_SECRET_KEYS` は「ブラウザでは絶対に使ってはならない」——RLSをバイパスするためです。

---

## 5. Webhook を冪等に受ける：中核パターン

Edge Functionsの最も価値の高い用途がWebhook受信です。そして最も壊れやすい用途でもあります。理由は単純で、**Webhookは必ず再送されるから**です。ネットワークの瞬断、タイムアウト、プロバイダ側のリトライ——同じイベントが2回届くのは異常ではなく仕様です。

正しい順序はこうです。

```text
1. 署名を検証する            ← 偽イベントを弾く
2. イベントIDを一意制約でINSERT ← 二重配送をDBで弾く
3. 即座に 2xx を返す          ← プロバイダのリトライを止める
4. 重い処理は waitUntil へ    ← レスポンス後に続行
```

まず記録テーブルです。冪等性の判定を**アプリのif文ではなくDBの一意制約に委ねる**のが要点です。同時に2件届いても、通るのは1件だけになります。

```sql
create table public.webhook_events (
  -- プロバイダ側のイベントID。これが冪等キー
  event_id text primary key,
  provider text not null,
  received_at timestamptz not null default now(),
  processed_at timestamptz,
  payload jsonb not null
);

alter table public.webhook_events enable row level security;
-- ポリシーを1本も作らない = 誰も読めない。書き込みは service_role のみ
```

関数側です。

> **署名検証のAPI名はプロバイダのSDKに依存します。** 以下はStripeを例にした骨格です。Denoランタイムでは同期的な署名検証ではなく**非同期版**と**Web Crypto ベースのプロバイダ**を使う点だけ、必ず利用中のSDKのバージョンに合わせて確認してください（バージョンによりヘルパー名が異なります）。骨格——「生ボディで検証 → 冪等キーでINSERT → 即2xx → 背景処理」——はプロバイダを問わず同じです。

```ts
import { withSupabase } from 'npm:@supabase/server@^1'
import type { SupabaseClient } from 'npm:@supabase/supabase-js@^2'
import Stripe from 'npm:stripe'

const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, {
  // Denoではfetchベースのクライアントを使う
  httpClient: Stripe.createFetchHttpClient(),
})
// 署名検証にはWeb Crypto（SubtleCrypto）ベースのプロバイダを使う
const cryptoProvider = Stripe.createSubtleCryptoProvider()
const webhookSecret = Deno.env.get('STRIPE_WEBHOOK_SECRET')!

export default {
  // Webhookは verify_jwt = false + auth: 'none'。認証は署名検証で行う
  fetch: withSupabase({ auth: 'none' }, async (req, ctx) => {
    const signature = req.headers.get('stripe-signature')
    if (!signature) return new Response('missing signature', { status: 400 })

    const body = await req.text() // 署名検証には生のボディが必要

    let event: Stripe.Event
    try {
      // 非同期版。Denoでは constructEvent ではなくこちらを使う
      event = await stripe.webhooks.constructEventAsync(
        body,
        signature,
        webhookSecret,
        undefined,
        cryptoProvider,
      )
    } catch {
      // 検証失敗の詳細は返さない（攻撃者への情報提供になる）
      return new Response('invalid signature', { status: 400 })
    }

    // 冪等性: 一意制約違反 = 処理済み
    const { error } = await ctx.supabaseAdmin.from('webhook_events').insert({
      event_id: event.id,
      provider: 'stripe',
      payload: event as unknown as Record<string, unknown>,
    })

    if (error) {
      // 23505 = unique_violation。既に受信済みなので 200 を返してリトライを止める
      if (error.code === '23505') return new Response('duplicate', { status: 200 })
      // それ以外は記録できていない = 再送してほしいので 5xx
      return new Response('storage error', { status: 500 })
    }

    // 重い処理はレスポンス後に。プロバイダのタイムアウトを踏まない
    EdgeRuntime.waitUntil(handleEvent(ctx, event))

    return new Response('ok', { status: 202 })
  }),
}

async function handleEvent(ctx: { supabaseAdmin: SupabaseClient }, event: Stripe.Event) {
  try {
    switch (event.type) {
      case 'checkout.session.completed':
        // 業務処理
        break
      default:
        break
    }
    await ctx.supabaseAdmin
      .from('webhook_events')
      .update({ processed_at: new Date().toISOString() })
      .eq('event_id', event.id)
  } catch (err) {
    // processed_at が null のまま残る = 再処理対象として可視化される
    console.error(JSON.stringify({ level: 'error', event_id: event.id, message: String(err) }))
  }
}
```

このパターンの設計判断を明示します。

- **署名検証を先にやる。** DBに書く前に弾かないと、偽イベントでテーブルを膨らませられます。
- **`23505`（一意制約違反）を 200 で返す。** ここを 500 にすると、プロバイダが永久にリトライします。
- **記録に失敗したら 5xx。** 「受け取ったのに記録できていない」状態は、再送してもらうのが正しい。
- **`processed_at` が null のまま残った行が、そのまま再処理キューになる。** 障害の可視化を別の仕組みに頼らずに済みます。
- **`202 Accepted` を返す。** 「受け取ったが処理はこれから」を正しく表すステータスです。

---

## 6. 背景タスク：レスポンスを返してから働く

`EdgeRuntime.waitUntil(promise)` は、渡したPromiseが完了するまで関数インスタンスを動かし続けます。公式の説明では「`waitUntil` に渡されたPromiseが完了するまで、関数インスタンスは動作を続ける」。

ハンドラの外でも中でも呼べます。

```ts
import { withSupabase } from 'npm:@supabase/server@^1'

export default {
  fetch: withSupabase({ auth: 'user' }, async (req, ctx) => {
    // レスポンスをブロックせずに背景処理を開始する
    EdgeRuntime.waitUntil(asyncLongRunningTask())
    return Response.json({ ok: true })
  }),
}
```

**無制限ではありません。** 公式の但し書きはこうです。

> 最大の実行時間は、ウォールクロック・CPU・メモリの制限に基づいて上限が決まる。いずれかの制限に達した時点で関数はシャットダウンする。

シャットダウンは検知できます。

```tsx
addEventListener('beforeunload', (ev) => {
  console.log('Function will be shutdown due to', ev.detail?.reason)
  // 状態を保存する / 現在の進捗をログに残す
})
```

これは**あってもなくてもよいコードではありません**。背景タスクが途中で切られたとき、何も残さなければ「消えた処理」になります。`beforeunload` で進捗を記録しておけば、次回の実行で再開点が分かります。

ローカルで背景タスクを試すには設定が必要です。

```toml
[edge_runtime]
policy = "per_worker"
```

この設定は自動リロードを無効にするため、コード変更のたびに `supabase functions serve` を再起動します。

### 背景タスクの限界を超えたら

数分で終わらない処理は、Edge Functionsの仕事ではありません。**受け取ってキューに積み、実行は別の場所で**という形に切り替えます。Supabase内で完結させるならDB側のCronとQueuesが自然な受け皿です。この判断は「まだ動くから」と先送りするほど高くつくので、想定処理時間が制限の半分に近づいたら移行を検討してください。

---

## 7. ストリーミング・WebSocket・一時ファイル

### WebSocket

`Deno.upgradeWebSocket()` が使えます。

```ts
export default {
  fetch: (req) => {
    const upgrade = req.headers.get('upgrade') || ''

    if (upgrade.toLowerCase() != 'websocket') {
      return Response.json(
        { error: "request isn't trying to upgrade to WebSocket." },
        { status: 400 }
      )
    }

    const { socket, response } = Deno.upgradeWebSocket(req)

    socket.onopen = () => console.log('socket opened')
    socket.onmessage = (e) => {
      console.log('socket message:', e.data)
      socket.send(new Date().toString())
    }
    socket.onerror = (e) => console.log('socket errored:', e.message)
    socket.onclose = () => console.log('socket closed')

    return response
  },
}
```

**認証に注意が必要です。** ブラウザはWebSocket接続時にカスタムヘッダを送れないため、公式は「URLのクエリパラメータ、またはカスタムプロトコル経由で認証情報を渡す」としています。デプロイ時は `--no-verify-jwt` フラグが必要です。

ただし率直に言えば、**クライアント間のリアルタイム通信は Supabase Realtime の仕事**です。Edge FunctionsのWebSocketが本当に必要なのは、外部WebSocket APIへの中継（リレー）のような、Realtimeでは表現できないケースに限られます（[Supabase Realtime 実装ガイド](/blog/supabase-realtime-broadcast-presence-postgres-changes-production-implementation-guide)）。

### 一時ファイル（`/tmp`）

```ts
const uploadId = crypto.randomUUID()
await Deno.writeFile('/tmp/' + uploadId, req.body)
const zipFile = await Deno.readFile('/tmp/' + uploadId)
```

容量はFreeで最大256MB、有料で最大512MB。そして最重要の性質がこれです。

> エフェメラルストレージは呼び出しごとにリセットされる。つまり、ある呼び出し中に書いたファイルは同じ呼び出しの中でしか読めない。

`/tmp` をキャッシュとして使うことはできません。また `Deno.statSync()` のような同期API は「初期スクリプト評価中のみ」動作し、HTTPハンドラやコールバック内では使えません。

---

## 8. テスト：純粋関数を切り出せば、ミリ秒で回る

Edge Functionsのテストが難しいと感じるなら、それは**ロジックがハンドラに埋まっている**サインです。公式のガイドも「副作用のない純粋関数としてビジネスロジックを書く」ことを推奨し、分離されたユニットテストは「ミリ秒で走る」としています。

推奨されるレイアウトはこうです。

```text
supabase/
├── functions/
│   ├── _shared/
│   │   └── types.ts
│   ├── process-ticket/
│   │   ├── index.ts      ← HTTPの受け口だけ
│   │   └── pricing.ts    ← 純粋なロジック
│   └── tests/
│       ├── utils/
│       │   └── supabase_env.ts
│       └── process-ticket/
│           ├── pricing.test.ts
│           └── index.test.ts
├── config.toml
└── deno.json
```

```ts
// supabase/functions/process-ticket/pricing.ts — 純粋。I/Oなし
export interface TicketInput {
  readonly basePriceJpy: number;
  readonly quantity: number;
  readonly isMember: boolean;
}

const MEMBER_DISCOUNT_RATE = 0.1;

/** 合計金額を計算する。丸めは切り捨てで統一（会計側と一致させる） */
export function calculateTotalJpy(input: TicketInput): number {
  if (!Number.isInteger(input.quantity) || input.quantity < 1) {
    throw new RangeError("quantity must be a positive integer");
  }
  const subtotal = input.basePriceJpy * input.quantity;
  const discount = input.isMember ? Math.floor(subtotal * MEMBER_DISCOUNT_RATE) : 0;
  return subtotal - discount;
}
```

```ts
// supabase/functions/tests/process-ticket/pricing.test.ts
import { assertEquals, assertThrows } from 'jsr:@std/assert'
import { calculateTotalJpy } from '../../process-ticket/pricing.ts'

Deno.test('会員は10%引きになる（端数は切り捨て）', () => {
  assertEquals(calculateTotalJpy({ basePriceJpy: 1050, quantity: 3, isMember: true }), 2835)
})

Deno.test('数量が0以下なら例外', () => {
  assertThrows(() => calculateTotalJpy({ basePriceJpy: 1000, quantity: 0, isMember: false }))
})
```

```bash
deno test supabase/functions/tests/process-ticket/pricing.test.ts
deno test supabase/functions/tests/process-ticket/index.test.ts --allow-env
deno task test
```

統合テストでは `globalThis.fetch` をモックします。公式の表現では「本番コードを変更せずにSupabaseのRESTコールを傍受でき、**本物のEdge Functionのコードパスをテストできる**」。ハンドラを書き換えずに経路全体を通せるのが利点です。

---

## 9. 可観測性：printf ではなく構造化ログ

自動で記録されるのは、実行中の**キャッチされなかった例外**、`console.log` / `console.error` / `console.warn` によるカスタムログ、そして**起動・終了ログ**です。ダッシュボードのFunctionsセクションで、Invocations（ヘッダ・ボディ・ステータスコード・実行時間を含むリクエスト/レスポンス）とLogs（プラットフォームイベント・例外・カスタムログ）を確認できます。

制限が実装に効きます。

- **1メッセージ最大10,000文字** — 巨大なオブジェクトをそのままダンプすると切れます
- **10秒あたり100イベント** — ループの中で `console.log` すると、必要なログが押し出されます

これを踏まえると、`console.log(payload)` は本番では役に立ちません。**構造化ログ**にします。

```ts
type LogLevel = 'info' | 'warn' | 'error';

interface LogFields {
  readonly event: string;
  /** 相関ID。1リクエストを横断して追える */
  readonly requestId: string;
  readonly [key: string]: unknown;
}

/** PIIを載せない・1行JSON・レベル付き。この3点だけ守れば後から検索できる */
export function log(level: LogLevel, fields: LogFields): void {
  const line = JSON.stringify({ level, ts: new Date().toISOString(), ...fields });
  // 10,000文字上限を超えると切れるため、超過分は明示的に落とす
  const safe = line.length > 9_500 ? `${line.slice(0, 9_500)}…"truncated":true}` : line;
  if (level === 'error') console.error(safe);
  else if (level === 'warn') console.warn(safe);
  else console.log(safe);
}
```

計測すべきものは3つです。

1. **エラー率**（関数別・エラー種別ごと）
2. **実行時間の分布**（p95がウォールクロック制限に近づいていないか）
3. **背景タスクの完走率**（`beforeunload` の発火回数 ÷ 開始回数）

3番目は自分で計測しない限り絶対に見えません。そして**背景タスクが静かに切られている**のは、Edge Functionsで最も発見が遅れる障害です。

---

## 10. デプロイとCI/CD

```yaml
name: Deploy Function

on:
  push:
    branches:
      - main
  workflow_dispatch:

jobs:
  deploy:
    runs-on: ubuntu-latest

    env:
      SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
      PROJECT_ID: your-project-id

    steps:
      - uses: actions/checkout@v4
      - uses: supabase/setup-cli@v1
        with:
          version: latest
      - run: supabase functions deploy --project-ref $PROJECT_ID
```

実務では、この前に**テストを挟みます**。

```yaml
      - uses: denoland/setup-deno@v2
        with:
          deno-version: v2.x
      - run: deno test supabase/functions/tests/ --allow-env
      - run: supabase functions deploy --project-ref $PROJECT_ID
```

`SUPABASE_ACCESS_TOKEN` はリポジトリのシークレットに置きます。**アプリケーションのシークレット（`STRIPE_SECRET_KEY` 等）はここでは扱いません** — それらは `supabase secrets set` でプロジェクトに設定するもので、CIのログに載る経路を作らないのが原則です。

---

## 11. コスト：回数課金であることの意味

| 項目 | Free | Pro |
| --- | --- | --- |
| 呼び出し回数 | 月50万回 込み | 月200万回 込み、以降 100万回あたり $2 |

**回数課金**という性質が、そのまま設計指針になります。

- **ポーリングをやめる。** 5秒ごとにステータスを問い合わせるクライアントが100人いれば、1日あたり約173万回。これだけで無料枠を超えます。同じことはRealtimeの購読で、呼び出し0回で実現できます。
- **関数を束ねる。** 第3章のルーティングは、コールドスタート削減と関数数上限だけでなく、運用の単純化にも効きます。
- **リトライを制御する。** クライアント側の無制限リトライは、障害時に呼び出し回数を指数的に増やします。指数バックオフと上限回数は必須です。

---

## 12. 本番前チェックリスト

- [ ] その処理のCPU時間が2秒に収まる根拠がある（重い変換・集計を載せていない）
- [ ] p95の実行時間がウォールクロック制限（Free 150秒 / 有料 400秒）に対して十分な余裕がある
- [ ] `withSupabase` の `auth` モードが、その関数の呼び出し元と一致している
- [ ] `ctx.supabaseAdmin` を使っている全ての箇所に、RLSをバイパスする正当な理由がコメントされている
- [ ] Webhook関数は `verify_jwt = false` + `auth: 'none'` + **署名検証**である
- [ ] Webhookの冪等性がDBの一意制約で担保されている（アプリのif文ではない）
- [ ] 重複配送に 200、記録失敗に 5xx を返している
- [ ] 背景タスクに `beforeunload` のハンドラがあり、進捗を残している
- [ ] 起動時に必須シークレットの存在を検証している
- [ ] `.env` が `.gitignore` にある
- [ ] ログが構造化されており、PIIを含まず、10,000文字を超えない
- [ ] ループ内で `console.log` していない（10秒100イベント制限）
- [ ] ロジックが純粋関数として分離され、`Deno.test` で回っている
- [ ] CIでテストを通してからデプロイしている
- [ ] クライアントのポーリングをRealtimeに置き換え済み（呼び出し回数課金）

---

## まとめ：制約が設計を教えてくれる

Edge Functionsの制約——CPU 2秒、メモリ256MB、実行時間150〜400秒——は、不便な制限ではなく**設計の指針**です。この枠に収まる処理は「入力を検証し、外部と話し、結果を記録する」という形をしています。それはまさに、アプリケーションのサーバーサイドが本来やるべき仕事の形です。

そして `@supabase/server` の `withSupabase` は、その仕事から定型を取り除きます。認証の検証、クライアントの生成、RLSのスコープ——これまで関数ごとにコピーしていたコードが、`auth` モードの1行の宣言になる。残るのは業務ロジックだけです。

最後にもう一度だけ強調します。**`ctx.supabase` を既定にし、`ctx.supabaseAdmin` は理由を書いてから使ってください。** Edge Functionsはサーバー側なので、何でもできてしまいます。何でもできる場所で、あえて権限を絞り続けられるかどうかが、そのシステムが数年後も安全であるかどうかを決めます。
