SupabaseのEdgeFunctionやDB関連をローカル環境で開発するときのTips

# EdgeFunction関連

ローカル開発では再起動と関数実行をひたすら繰り返すので、コマンドライン主体で修正サイクルを回すと早いです。

## 起動

起動

```bash
supabase functions serve
```

Background起動

```bash
supabase functions serve &
```

ログを書き出しながらバックグラウンド起動

```bash
supabase functions serve > functions.log  2>&1 &
```

バックグラウンドで起動したときの終了方法（fg + Ctrl-C以外）

```bash
pkill -f "supabase functions serve"
```

関数名を指定して起動。ただ今のところ関数をすべて起動してもそれほど重くなさそうなので一括で起動することが多いです。

```bash
supabase function serve somefunction
```

jwt認証を無効化して起動

```bash
supabase functions serve --no-verify-jwt
```

## 関数ごとにjwt認証無効など

`config.toml`に関数ごとの設定の既定値を書いておけます。

```toml:config.toml
[functions.somefunction]
verify_jwt = false
```

こうすると `supabase function serve` したり `supabase function deploy` したときに関数ごとに設定を個別に行うことができます。

## ポート番号変更
これも設定。複数プロジェクトをローカルで同時に動かすときには10ずつずらしています。

```toml:config.toml
[api]
enabled = true
# Port to use for the API URL.
port = 54411
```

## アクセストークンを取得（jq利用）

ローカルでは 「--no-verify-jwt すれば開発が捗る」とはいえ、RLSが有効になっているテーブルに対してEdgeFunctionを動作させる場合にはユーザーの認証情報を引き継いで検証しながら開発をする必要があります。

まずは存在するユーザーでJWTトークンを取得。

ポート番号を変えたりしているので supabase status で確認しながら取得。
認証するユーザーとパスワードは変数で定義。


```bash
USER=user1@kater.jp PASSWORD=password123
curl -s -X POST $(supabase status --output json | jq -r '.API_URL')'/auth/v1/token?grant_type=password' \
  -H "apikey: $(supabase status --output json | jq -r '.ANON_KEY')" \
  -H "Content-Type: application/json" \
  -d '{"email":"'${USER}'","password":"'${PASSWORD}'"}' | jq -r '.access_token'
```

## JWTを持ってEgdeFunctionを叩く

```bash
curl -H "Authorization: Bearer $ACCESS_TOKEN" $(supabase status --output json | jq -r '.API_URL')/functions/v1/somefunction
```

まとめて実行

```bash
USER=user1@kater.jp PASSWORD=password123
ACCCESS_TOKEN=$(curl -s -X POST $(supabase status --output json | jq -r '.API_URL')'/auth/v1/token?grant_type=password' \
  -H "apikey: $(supabase status --output json | jq -r '.ANON_KEY')" \
  -H "Content-Type: application/json" \
  -d '{"email":"'${USER}'","password":"'${PASSWORD}'"}' | jq -r '.access_token')

curl -H "Authorization: Bearer $ACCESS_TOKEN" $(supabase status --output json | jq -r '.API_URL')/functions/v1/somefunction
```


関数の再起動とまとめて実行

```bash
# 再起動
pkill -f "supabase functions serve" ; supabase functions serve > supabase/functions.log 2>&1 & sleep 2

# JWTの取得
USER=user1@kater.jp PASSWORD=password123
ACCCESS_TOKEN=$(curl -s -X POST $(supabase status --output json | jq -r '.API_URL')'/auth/v1/token?grant_type=password' \
  -H "apikey: $(supabase status --output json | jq -r '.ANON_KEY')" \
  -H "Content-Type: application/json" \
  -d '{"email":"'${USER}'","password":"'${PASSWORD}'"}' | jq -r '.access_token')

# 関数を叩く
curl -H "Authorization: Bearer $ACCESS_TOKEN" $(supabase status --output json | jq -r '.API_URL')/functions/v1/somefunction
```


## EdgeFunction内でユーザーをトークンのユーザに切り替え

```typescript

    const supabaseClient = createClient(
      supabaseUrl ?? '', supabaseAnonKey ?? '',
      {
        global: {
          headers: { Authorization: 'Bearer ' + req.headers.get('Authorization') || '' },
        },
      }
    )

    // ユーザーをANONからJWTのユーザーにすげ替える
    const token = authHeader.replace('Bearer ', '')
    const { data: { user }, error: authError } = await supabaseClient.auth.getUser(token)
```

下記の公式の記事を見ていたんですがうまくいかず。

https://supabase.com/docs/guides/functions/auth

で、[公式サンプル](https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/select-from-table-with-auth-rls/index.ts) とかを見ると `createClient` のときの第三引数に `global.headers` を与える必要があるっぽくて、それを与えるとうまくいきました。

下記です。

```typescript
createClient(
      supabaseUrl ?? '', supabaseAnonKey ?? '',
      {
        global: {
          headers: { Authorization: 'Bearer ' + req.headers.get('Authorization') || '' },
        },
      }
)
```

## DenoじゃなくてNodeで検証
EdgeFunctionの起動すら面倒なときはまずNodeで検証して移植すると良いです。でもDeno特有の問題とかあるので注意が必要。

<details><summary>Nodeで検証するサンプルコードと検証コマンド</summary>

```typescript:sample.ts

import { createClient } from '@supabase/supabase-js';

const SUPABASE_URL = 'http://127.0.0.1:54411';
const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0';

async function main(jwt: string) {
  console.log('Received JWT:', jwt);

  // Supabaseクライアントの作成
  const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY,
    {
      global: {
        headers: { Authorization: 'Bearer ' + jwt },
      },
    }
  );

  try {
    // ユーザー認証
    // const { data: user, error: authError } = await supabase.auth.getUser(jwt)
    const { data: user, error: authError } = await supabase.auth.getUser(jwt);

    console.log('認証成功:', user?.user?.email);

    // 画像の取得
    const { data: imageData, error: imageError } = await supabase
      .from('images')
      .select('*')
      .eq('id', '0287da2f-4058-891c-787e-7d68a7346ee8')
      .single();

    if (imageError) {
      throw imageError;
    }

    if (!imageData) {
      console.log('画像が見つかりませんでした');
      return;
    }

    console.log('取得した画像データ:', imageData);

  } catch (error) {
    console.error('エラーが発生しました:', error);
  }
}

// JWTを引数として渡して実行
if (process.argv.length < 3) {
  console.error('使用方法: ts-node get_image2.ts <JWT>');
  process.exit(1);
}

main(process.argv[2]);
```


```bash
USER=user1@kater.jp PASSWORD=password123
ACCCESS_TOKEN=$(curl -s -X POST $(supabase status --output json | jq -r '.API_URL')'/auth/v1/token?grant_type=password' \
  -H "apikey: $(supabase status --output json | jq -r '.ANON_KEY')" \
  -H "Content-Type: application/json" \
  -d '{"email":"'${USER}'","password":"'${PASSWORD}'"}' | jq -r '.access_token')

# 関数実行
ts-node sample.ts $ACCESS_TOKEN
```

</details>

## 参考

https://supabase.com/docs/guides/functions/local-quickstart

# DB関連

## 接続

psqlで接続。ポート番号とか変えてる事があるので supabase status を利用。

```bash
psql  "$(supabase status --output json | jq -r '.DB_URL')
```

## db reset マイグレーションからの再適応

```bash
supabase db reset
```

## db リセットせずにシーディングのみやり直し

`db reset` だと時間がかかるので開発用のシードの調整を行う場合には truncate -> シード読み込み で時間短縮できます。


```bash
curl -s https://gist.githubusercontent.com/yousan/a29149ffe7ba7459011a61cb9710674b/raw/06d03f4fd4a9593a719a4acffff92545ee6e0fab/truncate_all_data_at_public_schema.sql | psql "$(supabase status --output json | jq -r '.DB_URL')"
psql  "$(supabase status --output json | jq -r '.DB_URL')" < supabase/seed.sql ;
```

上記で使われるSQLのないｙ

```sql:truncate_all_data_atpublic_schema.sql
DO $$
DECLARE
  r RECORD;
BEGIN
  FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP
    EXECUTE 'TRUNCATE TABLE public.' || quote_ident(r.tablename) || ' CASCADE';
  END LOOP;
END $$;
```

複数のプロジェクトで使うのでGistに上げておきました。

https://gist.github.com/yousan/a29149ffe7ba7459011a61cb9710674b

このスクリプトを使ってDBのクリア。


# Storageとマイグレーション

## バケット作成
Supabaseをローカル開発しているとStorageの管理もマイグレーションで管理したくなります。
実際にAIなどを使うとマイグレーションでStorageを作成してくれます。
やり方は `storage.buckets` テーブルにINSERTをする、という方法です。

```sql:migrations/create_bucket.sql
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
  'original-images',
  'original-images',
  false, -- private bucket
  52428800, -- 50MB limit
  ARRAY['image/png', 'image/jpeg']::text[]
);
```

こうしてみるとストレージの実体は `storage.buckets` の行であるようです。

## バケット名の制約

この場合、INESRTではいろんな文字が通るようですが、コンソールだと通らないことがあります。
自分はアンダーバーを使っていたところ、マイグレーションでは通るがコンソールでは通らない、ということになりました。

![Screenshot 2025-05-24 at 23.50.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47327/200475df-6c98-442a-984f-d068d99eae00.png)

マイグレーションだと通ってしまいますが、互換性を考えて大人しくアンダーバーは使わないようにしたほうが良さそうです。

## バケットのRLS

ただ気をつけなければいけないのは、ローカルとSaaSでは色々と制約などがあり違うということです。

自分が躓いたのは「RLSが有効にできない」というものでした。
下記のSQLはローカルでは通りますがSaaSでは通りません。

```sql
ALTER TABLE storage.objects ENABLE ROW LEVEL SECURITY;
```

そもそもRLSを有効化していなくてもポリシーは設定できるので、マイグレーションではRLSの有効化は明示しなくてよさそうです。

## db reset 時に削除されないのでDELETEする

また、 `supabse db reset` した際に、SaaSではストレージのバケットは削除されません。ローカルでは削除されます。
そうすると `supabase db reset` がローカルでは削除されてマイグレーションで生成されるのに対し、SaaSでは重複エラーになってしまいます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47327/f8d59b36-a33d-429a-a9d9-13c9f634cfa6.png)

そのため、リセット時に消したい場合ではマイグレーションの最初のほうに削除を入れておくとよいです。

```sql
DELETE FROM storage.buckets WHERE id = 'original-images';

INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
  'original-images',
  'original-images',
  false, -- private bucket
  52428800, -- 50MB limit
  ARRAY['image/png', 'image/jpeg']::text[]
);
```