supabase-database-ops について
supabase-database-ops は、AIProDaily の開発で Supabase クエリやデータベース API を扱う際のガードレールです。テナント分離、サーバー側アクセス、取得フィールドの限定、エラーチェックを徹底し、テナント間のデータ漏えいリスクを抑えます。
主な機能
- 01テナントテーブルで publication_id を必須化
- 02supabaseAdmin のサーバー側利用を徹底
- 03不要な SELECT * を回避
- 04データベース操作のエラー確認
- 05挿入・更新・削除のパターン提示
活用シーン
- 01テナント範囲を限定した Supabase クエリの作成
- 02データベースにアクセスする API ルートの開発
- 03記事、キャンペーン、RSS データの変更
- 04行レベルセキュリティポリシーエラーの調査
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ venture-formations/ aiprodaily --skill supabase-database-opsインストール後、Claude Code で/supabase-database-opsを入力して読み込まれたか確認します。
詳細
Supabase Database Operations - Critical Guardrail
目的
AIProDaily プラットフォームでのマルチテナントデータ漏洩を防ぎ、データベースのベストプラクティスを徹底するための重大なガードレールです。
使用するタイミング
以下の場合、このスキルは検証が完了するまでデータベース操作をブロックします。
- Supabase クエリを作成するとき(
supabaseAdmin.from()) - テナントごとに分離されたテーブルにアクセスするとき
- データベースにアクセスする API ルートを作成するとき
- キャンペーン、記事、RSS データを扱うとき
🚨 重大なルール 🚨
ルール #1:必ず publication_id でフィルタリングする
テナントごとに分離されたテーブルに対するすべてのクエリに、publication_id フィルターを含める必要があります。
// ✅ CORRECT - publication_id filter present
const { data, error } = await supabaseAdmin
.from('newsletter_campaigns')
.select('id, status, date')
.eq('publication_id', newsletterId) // ✅ REQUIRED
.eq('id', campaignId)
.single()
// ❌ WRONG - Missing publication_id filter (DATA LEAKAGE!)
const { data, error } = await supabaseAdmin
.from('newsletter_campaigns')
.select('id, status, date')
.eq('id', campaignId) // ❌ Can access other tenants' data!
.single()
テナントごとに分離されたテーブル(必ず publication_id でフィルタリングする):
newsletter_campaignsarticlessecondary_articlesrss_postspost_ratingsrss_feedsapp_settingsadvertisementscampaign_advertisementsarchived_articlesarchived_rss_posts
分離されていないテーブル(publication_id は不要):
newsletters(最上位のテナントテーブル)- システム全体の設定テーブル
ルール #2:サーバーサイドの操作には supabaseAdmin を使用する
サービスロールキーをクライアントサイドに公開してはいけません。
// ✅ CORRECT - Server-side API route or Server Action
import { supabaseAdmin } from '@/lib/supabase'
export async function POST(request: NextRequest) {
const { data } = await supabaseAdmin
.from('newsletter_campaigns')
.select('*')
.eq('publication_id', newsletterId)
return NextResponse.json({ data })
}
// ❌ WRONG - Never in client components
'use client'
import { supabaseAdmin } from '@/lib/supabase' // ❌ Security risk!
export default function ClientComponent() {
// This exposes service role key to browser
const { data } = await supabaseAdmin.from('...').select()
}
supabaseAdmin を使用する場所:
- ✅ API ルート(
app/api/**/*.ts) - ✅ サーバーアクション(
'use server'関数) - ✅ サーバーコンポーネント(
'use client'がないもの) - ✅ バックグラウンドジョブ/cron
- ✅ ワークフローのステップ
使用してはいけない場所:
- ❌ クライアントコンポーネント(
'use client') - ❌ ブラウザーで実行されるコード
- ❌ 公開ページ
ルール #3:SELECT * を避ける
必要なフィールドのみを選択してください。
// ✅ CORRECT - Specific fields
const { data } = await supabaseAdmin
.from('articles')
.select('id, headline, article_text, is_active')
.eq('publication_id', newsletterId)
.eq('campaign_id', campaignId)
// ❌ WRONG - Fetches all columns (performance impact)
const { data } = await supabaseAdmin
.from('articles')
.select('*')
.eq('publication_id', newsletterId)
.eq('campaign_id', campaignId)
例外:データ操作ですべての列が本当に必要な場合。
ルール #4:必ずエラーを確認する
データベース操作が成功すると思い込んではいけません。
// ✅ CORRECT - Check for errors
const { data, error } = await supabaseAdmin
.from('newsletter_campaigns')
.select('id, status')
.eq('publication_id', newsletterId)
.eq('id', campaignId)
.single()
if (error) {
console.error('[DB] Query failed:', error.message)
throw new Error('Failed to fetch campaign')
}
if (!data) {
console.log('[DB] No campaign found')
return null
}
// Now safe to use data
return data
// ❌ WRONG - No error handling
const { data } = await supabaseAdmin
.from('newsletter_campaigns')
.select('id, status')
.eq('id', campaignId)
.single()
return data.status // ❌ Crashes if error or data is null
データベースクエリのパターン
標準的なクエリパターン
const { data, error } = await supabaseAdmin
.from('table_name')
.select('field1, field2, field3')
.eq('publication_id', newsletterId) // ✅ ALWAYS for tenant tables
.eq('other_field', value)
.single() // or .maybeSingle() if record might not exist
if (error) {
console.error('[DB] Query error:', error.message)
throw new Error(`Database query failed: ${error.message}`)
}
if (!data) {
console.log('[DB] No record found')
return null
}
return data
挿入パターン
const { data, error } = await supabaseAdmin
.from('articles')
.insert({
publication_id: newsletterId, // ✅ REQUIRED
campaign_id: campaignId,
headline: 'Article headline',
article_text: 'Content here',
is_active: false
})
.select()
.single()
if (error) {
console.error('[DB] Insert failed:', error.message)
throw new Error('Failed to create article')
}
return data
更新パターン
const { data, error } = await supabaseAdmin
.from('articles')
.update({
is_active: true,
updated_at: new Date().toISOString()
})
.eq('id', articleId)
.eq('publication_id', newsletterId) // ✅ REQUIRED - prevents updating other tenants
.select()
.single()
if (error) {
console.error('[DB] Update failed:', error.message)
throw new Error('Failed to update article')
}
return data
削除パターン
const { error } = await supabaseAdmin
.from('rss_posts')
.delete()
.eq('id', postId)
.eq('publication_id', newsletterId) // ✅ REQUIRED - prevents deleting other tenants' data
if (error) {
console.error('[DB] Delete failed:', error.message)
throw new Error('Failed to delete post')
}
結合パターン(リレーションシップ)
const { data, error } = await supabaseAdmin
.from('newsletter_campaigns')
.select(`
id,
status,
date,
articles (
id,
headline,
is_active
),
secondary_articles (
id,
headline,
is_active
)
`)
.eq('publication_id', newsletterId) // ✅ REQUIRED on parent table
.eq('id', campaignId)
.single()
よくある間違い
❌ publication_id フィルターを忘れる
// This query can access ANY campaign from ANY tenant!
const { data } = await supabaseAdmin
.from('newsletter_campaigns')
.select('*')
.eq('id', campaignId) // ❌ Missing publication_id
❌ クライアントサイドで supabaseAdmin を使用する
'use client'
// ❌ Exposes service role key to browser
export default function MyComponent() {
const { data } = await supabaseAdmin.from('...').select()
}
❌ エラー処理をしない
// ❌ No error check - will crash on failure
const { data } = await supabaseAdmin.from('...').select().single()
const status = data.status // Crashes if data is null
❌ SELECT * を使用する
// ❌ Fetches unnecessary data, impacts performance
const { data } = await supabaseAdmin
.from('articles')
.select('*')
クイックリファレンス
✅ 実行すること:
- テナントごとに分離されたテーブルでは、必ず
publication_idでフィルタリングする supabaseAdminはサーバーサイドでのみ使用する- 特定のフィールドを選択する
- エラーを確認する
- 単一のレコードには
.single()を使用する - レコードが存在しない可能性がある場合は
.maybeSingle()を使用する [DB]プレフィックスを付けてエラーをログに記録する
❌ 避けること:
publication_idフィルターを省略する- クライアントコンポーネントで
supabaseAdminを使用する - 理由なく
SELECT *を使用する - エラーを無視する
- データが存在すると決めつける
- サービスキーをクライアントサイドに公開する
エラーからの復旧
「Row level security policy violated」と表示された場合:
publication_idでフィルタリングしているか確認する- クライアントではなく
supabaseAdminを使用しているか確認する - サーバーサイド(API ルート/サーバーアクション)で実行していることを確認する
「column does not exist」と表示された場合:
- カラム名のスペルを確認する
- データベーススキーマにフィールドが存在するか確認する
- 正しいテーブルをクエリしていることを確認する
スキルの状態:アクティブなガードレール ✅ 適用レベル:ブロック(重大) 行数:< 500 ✅ 目的:マルチテナントデータの漏洩を防止 ✅
コメント
Markdown 対応