Supabase の Edge Function を定期実行させる覚書

Supabase の Edge Function を Cron で定期実行させた備忘録です。
定期実行の記録が残るようにデータベースの更新も行います。

「Supabase の Edge Function で Hello World する覚書」
https://qiita.com/jackalope/items/452ce72555eeed66a6f0
にて Edge Function が動作する事を確認出来た次のステップになります。

## 試した環境
- Windows11
- Next.js v15.1.6
- Supabase CLI 2.15.8
- Docker 27.4.0

そして通常そのようになってると思うので「**JWT認証が有効**」という前提にしてます。<br>

Supabase CLI についてはグローバルインストールしていないので npx で呼び出してます。
グローバルインストールしている場合は良しなに読み替えて下さい。

## 準備
Supabase のダッシュボードから API Settings を開くと見られるこれらの情報が必要です。
![APISettings.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4026777/e475c557-d1f8-47f8-9802-78aa548d98fd.png)

### Project URL
`https://{ProjectID}.supabase.co` の形式になっている URL になります。

### Project ID
Project URL は Project ID を含んだ `https://{ProjectID}.supabase.co` という形式になっているので、ここからでも抜き出せます。
以降 **{ProjectID}** と表記します。

### Service Role Key
API Settings の `service_role` の項目です。
以降 **{ServiceRole}** と表記します。

### ローカル実行用 anon key
`npx supabase start` した際に表示される `anon key` です。
以降 **{LocalAnonKey}** と表記します。

### 環境変数
環境変数が必要ですがシェルには設定しません。
一旦 `.env.function` というファイルに記述しておく事にします。
ファイル名に決まりは無いので判りやすければ何でも良いです。
```.env.function
PROJECT_URL="https://{ProjectID}.supabase.co"
SERVICE_ROLE_KEY="{ServiceRole}"
```

### 補足
Supabase にデプロイしたら Supabase 内で実行されるので Project URL や Service Role の設定は不要なのでは？
と思うかもしれませんが、Edge Functions はあくまで `Deno 実行環境で動作するサーバレス関数` という位置付けなので何も設定しないと DB への接続情報を持たない状態です。

## テーブルの用意
実験用に cron_log というテーブルを作成します。
![table.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4026777/a5640e72-5ddf-403f-aa1d-85a55f3e5904.png)

Table Editor から作っても良いですが以下の SQL でも作成出来ます。
RLS は設定する必要もないでしょう。
```SQL
CREATE TABLE public.cron_log (
    id BIGSERIAL PRIMARY KEY,
    created_at TIMESTAMP NOT NULL DEFAULT now(),
    cron_last_run TIMESTAMP,
    cron_count BIGINT NOT NULL DEFAULT 0
);
```

## Deno コード入力
それでは定期実行させるコードを作成します。名前は hello-cron としました。
> npx supabase functions new hello-cron

このコマンドで `supabase/functions/hello-cron/index.ts` が作成されるのでこれを編集します。
日本語のコメントを含める場合は必ず **UTF-8** で保存して下さい。
```index.ts
import "jsr:@supabase/functions-js/edge-runtime.d.ts"
import { createClient } from "https://esm.sh/@supabase/supabase-js";

const supabaseUrl = Deno.env.get("PROJECT_URL")!;
const supabaseServiceKey = Deno.env.get("SERVICE_ROLE_KEY")!;

const supabase = createClient(supabaseUrl, supabaseServiceKey);

Deno.serve(async (req) => {
  const clientName = req.headers.get("X-Supabase-Client-Name") ?? "";
  const isCronCall = clientName === "supabase-cron";

  try {
    // テーブルに行があるかどうか確認する
    const { data: row, error: selectError } = await supabase
      .from("cron_log")
      .select("*")
      .maybeSingle();

    if (selectError) {
      console.error("Select error:", selectError);
      return new Response("Failed to select row", { status: 500 });
    }

    if (!row) {
      // テーブルが空なので作成
      const { error: insertError } = await supabase
        .from("cron_log")
        .insert([{
          cron_count: 1,
          cron_last_run: new Date().toISOString(),
        }]);

      if (insertError) {
        console.error("Insert error:", insertError);
        return new Response("Failed to insert row", { status: 500 });
      }

      console.log("Inserted initial row for cron_log.");
    } else {
      // cron_count を +1, cron_last_run を更新
      const newCount = (row.cron_count ?? 0) + 1;

      const { error: updateError } = await supabase
        .from("cron_log")
        .update({
          cron_count: newCount,
          cron_last_run: new Date().toISOString(),
        })
        .eq("id", row.id)
        ;

      if (updateError) {
        console.error("Update error:", updateError);
        return new Response("Failed to update row", { status: 500 });
      }

      console.log(`Updated row. cron_count: ${newCount}`);
    }
  } catch (err) {
    console.error("Unexpected error:", err);
    return new Response("Unexpected server error", { status: 500 });
  }

  // ここまで来たら成功
  return new Response(
    JSON.stringify({ cronCall: isCronCall, status: "ok" }),
    { status: 200, headers: { "Content-Type": "application/json" } }
  );
})
```

デプロイの前に、データベースの操作が成功するか一応手元で動作確認しておきましょう。
まずサーバーの起動です。
> npx supabase functions serve hello-cron --env-file .env.function

ここで先ほど環境変数を記入したファイルが登場します。
コード内で環境変数を参照しているのでその設定になりますが、`--env-file` オプションでこのファイルを指定すると環境変数として設定してくれます。

サーバーを起動したら別コンソールから **curl** 実行します。
> curl -X POST http://127.0.0.1:54321/functions/v1/hello-cron -H "Authorization: Bearer {LocalAnonKey}"

正常に動作すれば以下のように表示されます。
> {"cronCall":false,"status":"ok"}

`cronCall` は Cron による呼び出しかどうかを示すフラグであり、今回は `false` になります。
Cron から呼び出された時以外は処理しない、などの用途に使用出来るかなと思いますが今回は使いません。
それでは Supabase のダッシュボードから `cron_log` テーブルを確認してみましょう。
上記 **curl** を実行させる度に `cron_last_run` のタイムスタンプが更新され、`cron_count` がインクリメントされているのが観察出来ると思います。ただダッシュボードでの見た目の更新は少し時間が掛かる場合もあります。

## デプロイ
デプロイする環境変数を設定します。
`.env.function` に記述した内容をそのまま `supabase secrets set` でセットするだけです。
```console
npx supabase secrets set PROJECT_URL="https://{ProjectID}.supabase.co"
npx supabase secrets set SERVICE_ROLE_KEY="{ServiceRole}"
```

設定した環境変数は
> npx supabase secrets list

で表示出来ますが、設定された値は暗号化されいるので内容までは確認出来ないので注意が必要です。

準備が出来たらデプロイします。
> npx supabase functions deploy hello-cron

これでダッシュボードの Edge Functions に登録されました。

一応デプロイしたコードも実行しておきましょう。
先ほどと違い本番用の Anon Key を指定します。ここでは **{AnonKey}** と表記します。
> curl -X POST https://{ProjectID}.supabase.co/functions/v1/hello-cron -H "Authorization: Bearer {AnonKey}"

問題無ければデータベースが更新されている筈です。

## Cron を設定する
Supabase のダッシュボードにある `Integrations` から `Cron` を有効化します。
![enable_cron.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4026777/72950f70-6f60-4d20-85e0-118fc67045c4.png)

有効化したら Integrations から Cron を選択し
![Cron.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4026777/2d7cf91f-2fc1-4704-9d2b-8febb697e267.png)

Jobs タブ内にある `Create job` ボタンを押すと Cron 設定画面が開きます。
この時以下のように Type の項目で Supabase Edge Function が選択出来ない状態になっている事がありますがこれは Edge Function の動作に pg_net が必要だからです。
![pg_net.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4026777/453cc7a7-6dd2-459b-8713-bf4d857d05f7.png)

`Install pg_net extension` ボタンから `pg_net` をインストールしましょう。

schema は extensions のままで大丈夫です。
![pg_net_schema.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4026777/8bfe23b3-2743-498e-b3fd-35c894461409.png)


改めて `Create job` の設定に戻ります。
試しに 1分毎で動作するジョブにしてみます。
- 名前は `hello cron every minute` としておきます
- Schedule は `Every minute` ボタンを押すと自動で設定されます
![new_job1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4026777/b7b63263-f099-422d-a5ff-ea796b6a34c9.png)

Function をセットします
- Type は `Supabase Edge Function` を選択
- Edge Function には `hello-cron` をセット
![new_job2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4026777/d6761215-f521-4833-b489-d11a0fec9409.png)

ここ大事、この設定により Authorization ヘッダが付与されます。
- HTTP Headers で `Add auth header with service key` を選択
![new_job3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4026777/ae6b91df-e1af-4df7-9e82-fa9d1f6c12e0.png)

完成です。
Save するとこのようなジョブが出来ます。Active がオンになっていれば稼働しているので 1分毎に cron_log テーブルが更新される事を観察出来ると思います。
![new_job4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4026777/77470a27-0d8a-488e-8957-68cb9b4f5efe.png)

※ 無駄な負荷になるので確認が終わったら Active をオフにしておきましょう。

これで Supabase Edge Function の定期実行を試すことが出来ました。