Added Claude Skills
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
---
|
||||
name: auth-integration
|
||||
description: Authentication patterns and JWT handling for Supabase applications
|
||||
parent-skill: moai-platform-supabase
|
||||
version: 1.0.0
|
||||
updated: 2026-01-06
|
||||
---
|
||||
|
||||
# Auth Integration Module
|
||||
|
||||
## Overview
|
||||
|
||||
Supabase Auth provides authentication with multiple providers, JWT-based sessions, and seamless integration with Row-Level Security policies.
|
||||
|
||||
## Client Setup
|
||||
|
||||
### Browser Client
|
||||
|
||||
```typescript
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
)
|
||||
```
|
||||
|
||||
### Server-Side Client (Next.js App Router)
|
||||
|
||||
```typescript
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { cookies } from 'next/headers'
|
||||
import { Database } from './database.types'
|
||||
|
||||
export function createServerSupabase() {
|
||||
const cookieStore = cookies()
|
||||
|
||||
return createServerClient<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
get(name: string) {
|
||||
return cookieStore.get(name)?.value
|
||||
},
|
||||
set(name, value, options) {
|
||||
cookieStore.set({ name, value, ...options })
|
||||
},
|
||||
remove(name, options) {
|
||||
cookieStore.set({ name, value: '', ...options })
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Middleware Client (Next.js)
|
||||
|
||||
```typescript
|
||||
// middleware.ts
|
||||
import { createServerClient, type CookieOptions } from '@supabase/ssr'
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
let response = NextResponse.next({
|
||||
request: { headers: request.headers }
|
||||
})
|
||||
|
||||
const supabase = createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
get(name: string) {
|
||||
return request.cookies.get(name)?.value
|
||||
},
|
||||
set(name: string, value: string, options: CookieOptions) {
|
||||
request.cookies.set({ name, value, ...options })
|
||||
response.cookies.set({ name, value, ...options })
|
||||
},
|
||||
remove(name: string, options: CookieOptions) {
|
||||
request.cookies.set({ name, value: '', ...options })
|
||||
response.cookies.set({ name, value: '', ...options })
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await supabase.auth.getUser()
|
||||
return response
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)']
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication Methods
|
||||
|
||||
### Email/Password Sign Up
|
||||
|
||||
```typescript
|
||||
async function signUp(email: string, password: string) {
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
emailRedirectTo: `${window.location.origin}/auth/callback`
|
||||
}
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
```
|
||||
|
||||
### Email/Password Sign In
|
||||
|
||||
```typescript
|
||||
async function signIn(email: string, password: string) {
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
```
|
||||
|
||||
### OAuth Provider
|
||||
|
||||
```typescript
|
||||
async function signInWithGoogle() {
|
||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
options: {
|
||||
redirectTo: `${window.location.origin}/auth/callback`,
|
||||
queryParams: {
|
||||
access_type: 'offline',
|
||||
prompt: 'consent'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
```
|
||||
|
||||
### Magic Link
|
||||
|
||||
```typescript
|
||||
async function signInWithMagicLink(email: string) {
|
||||
const { data, error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
emailRedirectTo: `${window.location.origin}/auth/callback`
|
||||
}
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
```
|
||||
|
||||
## Session Management
|
||||
|
||||
### Get Current User
|
||||
|
||||
```typescript
|
||||
async function getCurrentUser() {
|
||||
const { data: { user }, error } = await supabase.auth.getUser()
|
||||
if (error) throw error
|
||||
return user
|
||||
}
|
||||
```
|
||||
|
||||
### Get Session
|
||||
|
||||
```typescript
|
||||
async function getSession() {
|
||||
const { data: { session }, error } = await supabase.auth.getSession()
|
||||
if (error) throw error
|
||||
return session
|
||||
}
|
||||
```
|
||||
|
||||
### Sign Out
|
||||
|
||||
```typescript
|
||||
async function signOut() {
|
||||
const { error } = await supabase.auth.signOut()
|
||||
if (error) throw error
|
||||
}
|
||||
```
|
||||
|
||||
### Listen to Auth Changes
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||||
(event, session) => {
|
||||
if (event === 'SIGNED_IN') {
|
||||
console.log('User signed in:', session?.user)
|
||||
}
|
||||
if (event === 'SIGNED_OUT') {
|
||||
console.log('User signed out')
|
||||
}
|
||||
if (event === 'TOKEN_REFRESHED') {
|
||||
console.log('Token refreshed')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return () => subscription.unsubscribe()
|
||||
}, [])
|
||||
```
|
||||
|
||||
## Auth Callback Handler
|
||||
|
||||
### Next.js App Router
|
||||
|
||||
```typescript
|
||||
// app/auth/callback/route.ts
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { cookies } from 'next/headers'
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams, origin } = new URL(request.url)
|
||||
const code = searchParams.get('code')
|
||||
const next = searchParams.get('next') ?? '/'
|
||||
|
||||
if (code) {
|
||||
const cookieStore = cookies()
|
||||
const supabase = createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
get(name: string) {
|
||||
return cookieStore.get(name)?.value
|
||||
},
|
||||
set(name, value, options) {
|
||||
cookieStore.set({ name, value, ...options })
|
||||
},
|
||||
remove(name, options) {
|
||||
cookieStore.set({ name, value: '', ...options })
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const { error } = await supabase.auth.exchangeCodeForSession(code)
|
||||
if (!error) {
|
||||
return NextResponse.redirect(`${origin}${next}`)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.redirect(`${origin}/auth/error`)
|
||||
}
|
||||
```
|
||||
|
||||
## Protected Routes
|
||||
|
||||
### Server Component Protection
|
||||
|
||||
```typescript
|
||||
// app/dashboard/page.tsx
|
||||
import { redirect } from 'next/navigation'
|
||||
import { createServerSupabase } from '@/lib/supabase/server'
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const supabase = createServerSupabase()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
return <Dashboard user={user} />
|
||||
}
|
||||
```
|
||||
|
||||
### Client Component Protection
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
export function useRequireAuth() {
|
||||
const [user, setUser] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
supabase.auth.getUser().then(({ data: { user } }) => {
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
} else {
|
||||
setUser(user)
|
||||
}
|
||||
setLoading(false)
|
||||
})
|
||||
}, [router])
|
||||
|
||||
return { user, loading }
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Claims
|
||||
|
||||
### Setting Custom Claims (Edge Function)
|
||||
|
||||
```typescript
|
||||
// supabase/functions/set-claims/index.ts
|
||||
serve(async (req) => {
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
||||
)
|
||||
|
||||
const { userId, claims } = await req.json()
|
||||
|
||||
// Update user metadata (available in JWT)
|
||||
const { error } = await supabase.auth.admin.updateUserById(userId, {
|
||||
app_metadata: claims
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
|
||||
return new Response(JSON.stringify({ success: true }))
|
||||
})
|
||||
```
|
||||
|
||||
### Reading Claims in RLS
|
||||
|
||||
```sql
|
||||
-- Access claims in RLS policies
|
||||
CREATE POLICY "admin_only" ON admin_data FOR ALL
|
||||
USING ((auth.jwt() ->> 'role')::text = 'admin');
|
||||
```
|
||||
|
||||
## Password Reset
|
||||
|
||||
```typescript
|
||||
async function resetPassword(email: string) {
|
||||
const { data, error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: `${window.location.origin}/auth/reset-password`
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
async function updatePassword(newPassword: string) {
|
||||
const { data, error } = await supabase.auth.updateUser({
|
||||
password: newPassword
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
```
|
||||
|
||||
## Context7 Query Examples
|
||||
|
||||
For latest Auth documentation:
|
||||
|
||||
Topic: "supabase auth signIn signUp"
|
||||
Topic: "supabase ssr server client"
|
||||
Topic: "auth jwt claims custom"
|
||||
|
||||
---
|
||||
|
||||
Related Modules:
|
||||
- row-level-security.md - Auth integration with RLS
|
||||
- typescript-patterns.md - Type-safe auth patterns
|
||||
- edge-functions.md - Server-side auth verification
|
||||
@@ -0,0 +1,371 @@
|
||||
---
|
||||
name: edge-functions
|
||||
description: Serverless Deno functions at the edge with authentication and rate limiting
|
||||
parent-skill: moai-platform-supabase
|
||||
version: 1.0.0
|
||||
updated: 2026-01-06
|
||||
---
|
||||
|
||||
# Edge Functions Module
|
||||
|
||||
## Overview
|
||||
|
||||
Supabase Edge Functions are serverless functions running on the Deno runtime at the edge, providing low-latency responses globally.
|
||||
|
||||
## Basic Edge Function
|
||||
|
||||
### Function Structure
|
||||
|
||||
```typescript
|
||||
// supabase/functions/api/index.ts
|
||||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type'
|
||||
}
|
||||
|
||||
serve(async (req) => {
|
||||
// Handle CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders })
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
||||
)
|
||||
|
||||
// Process request
|
||||
const body = await req.json()
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, data: body }),
|
||||
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
} catch (error) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: error.message }),
|
||||
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
### JWT Token Verification
|
||||
|
||||
```typescript
|
||||
serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders })
|
||||
}
|
||||
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
||||
)
|
||||
|
||||
// Verify JWT token
|
||||
const authHeader = req.headers.get('authorization')
|
||||
if (!authHeader) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Unauthorized' }),
|
||||
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
const { data: { user }, error } = await supabase.auth.getUser(
|
||||
authHeader.replace('Bearer ', '')
|
||||
)
|
||||
|
||||
if (error || !user) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Invalid token' }),
|
||||
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
// User is authenticated, proceed with request
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, user_id: user.id }),
|
||||
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
### Using User Context Client
|
||||
|
||||
Create a client that inherits user permissions:
|
||||
|
||||
```typescript
|
||||
serve(async (req) => {
|
||||
const authHeader = req.headers.get('authorization')!
|
||||
|
||||
// Client with user's permissions (respects RLS)
|
||||
const supabaseUser = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_ANON_KEY')!,
|
||||
{ global: { headers: { Authorization: authHeader } } }
|
||||
)
|
||||
|
||||
// This query respects RLS policies
|
||||
const { data, error } = await supabaseUser
|
||||
.from('projects')
|
||||
.select('*')
|
||||
|
||||
return new Response(JSON.stringify({ data }))
|
||||
})
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
### Database-Based Rate Limiting
|
||||
|
||||
```sql
|
||||
-- Rate limits table
|
||||
CREATE TABLE rate_limits (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
identifier TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_rate_limits_lookup ON rate_limits(identifier, created_at);
|
||||
```
|
||||
|
||||
### Rate Limit Function
|
||||
|
||||
```typescript
|
||||
async function checkRateLimit(
|
||||
supabase: SupabaseClient,
|
||||
identifier: string,
|
||||
limit: number,
|
||||
windowSeconds: number
|
||||
): Promise<boolean> {
|
||||
const windowStart = new Date(Date.now() - windowSeconds * 1000).toISOString()
|
||||
|
||||
const { count } = await supabase
|
||||
.from('rate_limits')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('identifier', identifier)
|
||||
.gte('created_at', windowStart)
|
||||
|
||||
if (count && count >= limit) {
|
||||
return false
|
||||
}
|
||||
|
||||
await supabase.from('rate_limits').insert({ identifier })
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
### Usage in Edge Function
|
||||
|
||||
```typescript
|
||||
serve(async (req) => {
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
||||
)
|
||||
|
||||
// Get client identifier (IP or user ID)
|
||||
const identifier = req.headers.get('x-forwarded-for') || 'anonymous'
|
||||
|
||||
// 100 requests per minute
|
||||
const allowed = await checkRateLimit(supabase, identifier, 100, 60)
|
||||
|
||||
if (!allowed) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Rate limit exceeded' }),
|
||||
{ status: 429, headers: corsHeaders }
|
||||
)
|
||||
}
|
||||
|
||||
// Process request...
|
||||
})
|
||||
```
|
||||
|
||||
## External API Integration
|
||||
|
||||
### Webhook Handler
|
||||
|
||||
```typescript
|
||||
serve(async (req) => {
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
||||
)
|
||||
|
||||
// Verify webhook signature
|
||||
const signature = req.headers.get('x-webhook-signature')
|
||||
const body = await req.text()
|
||||
|
||||
const expectedSignature = await crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
new TextEncoder().encode(body + Deno.env.get('WEBHOOK_SECRET'))
|
||||
)
|
||||
|
||||
if (!verifySignature(signature, expectedSignature)) {
|
||||
return new Response('Invalid signature', { status: 401 })
|
||||
}
|
||||
|
||||
const payload = JSON.parse(body)
|
||||
|
||||
// Process webhook
|
||||
await supabase.from('webhook_events').insert({
|
||||
type: payload.type,
|
||||
data: payload.data,
|
||||
processed: false
|
||||
})
|
||||
|
||||
return new Response('OK', { status: 200 })
|
||||
})
|
||||
```
|
||||
|
||||
### External API Call
|
||||
|
||||
```typescript
|
||||
serve(async (req) => {
|
||||
const { query } = await req.json()
|
||||
|
||||
// Call external API
|
||||
const response = await fetch('https://api.openai.com/v1/embeddings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${Deno.env.get('OPENAI_API_KEY')}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'text-embedding-ada-002',
|
||||
input: query
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ embedding: data.data[0].embedding }),
|
||||
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Structured Error Response
|
||||
|
||||
```typescript
|
||||
interface ErrorResponse {
|
||||
error: string
|
||||
code: string
|
||||
details?: unknown
|
||||
}
|
||||
|
||||
function errorResponse(
|
||||
message: string,
|
||||
code: string,
|
||||
status: number,
|
||||
details?: unknown
|
||||
): Response {
|
||||
const body: ErrorResponse = { error: message, code, details }
|
||||
return new Response(
|
||||
JSON.stringify(body),
|
||||
{ status, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
serve(async (req) => {
|
||||
try {
|
||||
// ... processing
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return errorResponse('Authentication failed', 'AUTH_ERROR', 401)
|
||||
}
|
||||
if (error instanceof ValidationError) {
|
||||
return errorResponse('Invalid input', 'VALIDATION_ERROR', 400, error.details)
|
||||
}
|
||||
console.error('Unexpected error:', error)
|
||||
return errorResponse('Internal server error', 'INTERNAL_ERROR', 500)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Local Development
|
||||
|
||||
```bash
|
||||
supabase functions serve api --env-file .env.local
|
||||
```
|
||||
|
||||
### Deploy Function
|
||||
|
||||
```bash
|
||||
supabase functions deploy api
|
||||
```
|
||||
|
||||
### Set Secrets
|
||||
|
||||
```bash
|
||||
supabase secrets set OPENAI_API_KEY=sk-xxx
|
||||
supabase secrets set WEBHOOK_SECRET=whsec-xxx
|
||||
```
|
||||
|
||||
### List Functions
|
||||
|
||||
```bash
|
||||
supabase functions list
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Cold Start Optimization
|
||||
|
||||
Keep imports minimal at the top level:
|
||||
|
||||
```typescript
|
||||
// Good: Import only what's needed
|
||||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
|
||||
|
||||
// Bad: Heavy imports at top level increase cold start
|
||||
// import { everything } from 'large-library'
|
||||
```
|
||||
|
||||
### Response Streaming
|
||||
|
||||
Stream large responses:
|
||||
|
||||
```typescript
|
||||
serve(async (req) => {
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
controller.enqueue(new TextEncoder().encode(`data: ${i}\n\n`))
|
||||
}
|
||||
controller.close()
|
||||
}
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: { 'Content-Type': 'text/event-stream' }
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Context7 Query Examples
|
||||
|
||||
For latest Edge Functions documentation:
|
||||
|
||||
Topic: "edge functions deno runtime"
|
||||
Topic: "supabase functions deploy secrets"
|
||||
Topic: "edge functions cors authentication"
|
||||
|
||||
---
|
||||
|
||||
Related Modules:
|
||||
- auth-integration.md - Authentication patterns
|
||||
- typescript-patterns.md - Client invocation
|
||||
@@ -0,0 +1,231 @@
|
||||
---
|
||||
name: postgresql-pgvector
|
||||
description: PostgreSQL 16 with pgvector extension for AI embeddings and semantic search
|
||||
parent-skill: moai-platform-supabase
|
||||
version: 1.0.0
|
||||
updated: 2026-01-06
|
||||
---
|
||||
|
||||
# PostgreSQL 16 + pgvector Module
|
||||
|
||||
## Overview
|
||||
|
||||
PostgreSQL 16 with pgvector extension enables AI-powered semantic search through vector embeddings storage and similarity search operations.
|
||||
|
||||
## Extension Setup
|
||||
|
||||
Enable required extensions for vector operations:
|
||||
|
||||
```sql
|
||||
-- Enable required extensions
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
```
|
||||
|
||||
## Embeddings Table Schema
|
||||
|
||||
Create a table optimized for storing AI embeddings:
|
||||
|
||||
```sql
|
||||
CREATE TABLE documents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
content TEXT NOT NULL,
|
||||
embedding vector(1536), -- OpenAI ada-002 dimensions
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
### Common Embedding Dimensions
|
||||
|
||||
- OpenAI ada-002: 1536 dimensions
|
||||
- OpenAI text-embedding-3-small: 1536 dimensions
|
||||
- OpenAI text-embedding-3-large: 3072 dimensions
|
||||
- Cohere embed-english-v3.0: 1024 dimensions
|
||||
- Google PaLM: 768 dimensions
|
||||
|
||||
## Index Strategies
|
||||
|
||||
### HNSW Index (Recommended)
|
||||
|
||||
HNSW (Hierarchical Navigable Small World) provides fast approximate nearest neighbor search:
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_documents_embedding ON documents
|
||||
USING hnsw (embedding vector_cosine_ops)
|
||||
WITH (m = 16, ef_construction = 64);
|
||||
```
|
||||
|
||||
Parameters:
|
||||
- m: Maximum number of connections per layer (default 16, higher = more accurate but slower)
|
||||
- ef_construction: Size of dynamic candidate list during construction (default 64)
|
||||
|
||||
### IVFFlat Index (Large Datasets)
|
||||
|
||||
IVFFlat is better for datasets with millions of rows:
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_documents_ivf ON documents
|
||||
USING ivfflat (embedding vector_cosine_ops)
|
||||
WITH (lists = 100);
|
||||
```
|
||||
|
||||
Guidelines for lists parameter:
|
||||
- Less than 1M rows: lists = rows / 1000
|
||||
- More than 1M rows: lists = sqrt(rows)
|
||||
|
||||
## Distance Operations
|
||||
|
||||
Available distance operators:
|
||||
|
||||
- `<->` - Euclidean distance (L2)
|
||||
- `<#>` - Negative inner product
|
||||
- `<=>` - Cosine distance
|
||||
|
||||
For normalized embeddings, cosine distance is recommended.
|
||||
|
||||
## Semantic Search Function
|
||||
|
||||
Basic semantic search with threshold and limit:
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION search_documents(
|
||||
query_embedding vector(1536),
|
||||
match_threshold FLOAT DEFAULT 0.8,
|
||||
match_count INT DEFAULT 10
|
||||
) RETURNS TABLE (id UUID, content TEXT, similarity FLOAT)
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
RETURN QUERY SELECT d.id, d.content,
|
||||
1 - (d.embedding <=> query_embedding) AS similarity
|
||||
FROM documents d
|
||||
WHERE 1 - (d.embedding <=> query_embedding) > match_threshold
|
||||
ORDER BY d.embedding <=> query_embedding
|
||||
LIMIT match_count;
|
||||
END; $$;
|
||||
```
|
||||
|
||||
### Usage Example
|
||||
|
||||
```sql
|
||||
SELECT * FROM search_documents(
|
||||
'[0.1, 0.2, ...]'::vector(1536),
|
||||
0.75,
|
||||
20
|
||||
);
|
||||
```
|
||||
|
||||
## Hybrid Search
|
||||
|
||||
Combine vector similarity with full-text search for better results:
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION hybrid_search(
|
||||
query_text TEXT,
|
||||
query_embedding vector(1536),
|
||||
match_count INT DEFAULT 10,
|
||||
full_text_weight FLOAT DEFAULT 0.3,
|
||||
semantic_weight FLOAT DEFAULT 0.7
|
||||
) RETURNS TABLE (id UUID, content TEXT, score FLOAT) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
WITH semantic AS (
|
||||
SELECT e.id, e.content, 1 - (e.embedding <=> query_embedding) AS similarity
|
||||
FROM documents e ORDER BY e.embedding <=> query_embedding LIMIT match_count * 2
|
||||
),
|
||||
full_text AS (
|
||||
SELECT e.id, e.content,
|
||||
ts_rank(to_tsvector('english', e.content), plainto_tsquery('english', query_text)) AS rank
|
||||
FROM documents e
|
||||
WHERE to_tsvector('english', e.content) @@ plainto_tsquery('english', query_text)
|
||||
LIMIT match_count * 2
|
||||
)
|
||||
SELECT COALESCE(s.id, f.id), COALESCE(s.content, f.content),
|
||||
(COALESCE(s.similarity, 0) * semantic_weight + COALESCE(f.rank, 0) * full_text_weight)
|
||||
FROM semantic s FULL OUTER JOIN full_text f ON s.id = f.id
|
||||
ORDER BY 3 DESC LIMIT match_count;
|
||||
END; $$ LANGUAGE plpgsql;
|
||||
```
|
||||
|
||||
## Full-Text Search Index
|
||||
|
||||
Add GIN index for efficient full-text search:
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_documents_content_fts ON documents
|
||||
USING gin(to_tsvector('english', content));
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Query Performance
|
||||
|
||||
Set appropriate ef_search for HNSW queries:
|
||||
|
||||
```sql
|
||||
SET hnsw.ef_search = 100; -- Higher = more accurate, slower
|
||||
```
|
||||
|
||||
### Batch Insertions
|
||||
|
||||
Use COPY or multi-row INSERT for bulk embeddings:
|
||||
|
||||
```sql
|
||||
INSERT INTO documents (content, embedding, metadata)
|
||||
VALUES
|
||||
('Content 1', '[...]'::vector(1536), '{"source": "doc1"}'),
|
||||
('Content 2', '[...]'::vector(1536), '{"source": "doc2"}'),
|
||||
('Content 3', '[...]'::vector(1536), '{"source": "doc3"}');
|
||||
```
|
||||
|
||||
### Index Maintenance
|
||||
|
||||
Reindex after large bulk insertions:
|
||||
|
||||
```sql
|
||||
REINDEX INDEX CONCURRENTLY idx_documents_embedding;
|
||||
```
|
||||
|
||||
## Metadata Filtering
|
||||
|
||||
Combine vector search with JSONB metadata filters:
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION search_with_filters(
|
||||
query_embedding vector(1536),
|
||||
filter_metadata JSONB,
|
||||
match_count INT DEFAULT 10
|
||||
) RETURNS TABLE (id UUID, content TEXT, similarity FLOAT) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY SELECT d.id, d.content,
|
||||
1 - (d.embedding <=> query_embedding) AS similarity
|
||||
FROM documents d
|
||||
WHERE d.metadata @> filter_metadata
|
||||
ORDER BY d.embedding <=> query_embedding
|
||||
LIMIT match_count;
|
||||
END; $$;
|
||||
```
|
||||
|
||||
### Usage with Filters
|
||||
|
||||
```sql
|
||||
SELECT * FROM search_with_filters(
|
||||
'[0.1, 0.2, ...]'::vector(1536),
|
||||
'{"category": "technical", "language": "en"}'::jsonb,
|
||||
10
|
||||
);
|
||||
```
|
||||
|
||||
## Context7 Query Examples
|
||||
|
||||
For latest pgvector documentation:
|
||||
|
||||
Topic: "pgvector extension indexes hnsw ivfflat"
|
||||
Topic: "vector similarity search operators"
|
||||
Topic: "postgresql full-text search tsvector"
|
||||
|
||||
---
|
||||
|
||||
Related Modules:
|
||||
- row-level-security.md - Secure vector data access
|
||||
- typescript-patterns.md - Client-side search implementation
|
||||
@@ -0,0 +1,354 @@
|
||||
---
|
||||
name: realtime-presence
|
||||
description: Real-time subscriptions and presence tracking for collaborative features
|
||||
parent-skill: moai-platform-supabase
|
||||
version: 1.0.0
|
||||
updated: 2026-01-06
|
||||
---
|
||||
|
||||
# Real-time and Presence Module
|
||||
|
||||
## Overview
|
||||
|
||||
Supabase provides real-time capabilities through Postgres Changes (database change notifications) and Presence (user state tracking) for building collaborative applications.
|
||||
|
||||
## Postgres Changes Subscription
|
||||
|
||||
### Basic Setup
|
||||
|
||||
Subscribe to all changes on a table:
|
||||
|
||||
```typescript
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
|
||||
|
||||
const channel = supabase.channel('db-changes')
|
||||
.on('postgres_changes',
|
||||
{ event: '*', schema: 'public', table: 'messages' },
|
||||
(payload) => console.log('Change:', payload)
|
||||
)
|
||||
.subscribe()
|
||||
```
|
||||
|
||||
### Event Types
|
||||
|
||||
Available events:
|
||||
- `INSERT` - New row added
|
||||
- `UPDATE` - Row modified
|
||||
- `DELETE` - Row removed
|
||||
- `*` - All events
|
||||
|
||||
### Filtered Subscriptions
|
||||
|
||||
Filter changes by specific conditions:
|
||||
|
||||
```typescript
|
||||
supabase.channel('project-updates')
|
||||
.on('postgres_changes',
|
||||
{
|
||||
event: 'UPDATE',
|
||||
schema: 'public',
|
||||
table: 'projects',
|
||||
filter: `id=eq.${projectId}`
|
||||
},
|
||||
(payload) => handleProjectUpdate(payload.new)
|
||||
)
|
||||
.subscribe()
|
||||
```
|
||||
|
||||
### Multiple Tables
|
||||
|
||||
Subscribe to multiple tables on one channel:
|
||||
|
||||
```typescript
|
||||
const channel = supabase.channel('app-changes')
|
||||
.on('postgres_changes',
|
||||
{ event: '*', schema: 'public', table: 'tasks' },
|
||||
handleTaskChange
|
||||
)
|
||||
.on('postgres_changes',
|
||||
{ event: '*', schema: 'public', table: 'comments' },
|
||||
handleCommentChange
|
||||
)
|
||||
.subscribe()
|
||||
```
|
||||
|
||||
## Presence Tracking
|
||||
|
||||
### Presence State Interface
|
||||
|
||||
```typescript
|
||||
interface PresenceState {
|
||||
user_id: string
|
||||
online_at: string
|
||||
typing?: boolean
|
||||
cursor?: { x: number; y: number }
|
||||
}
|
||||
```
|
||||
|
||||
### Channel Setup with Presence
|
||||
|
||||
```typescript
|
||||
const channel = supabase.channel('room:collaborative-doc', {
|
||||
config: { presence: { key: userId } }
|
||||
})
|
||||
|
||||
channel
|
||||
.on('presence', { event: 'sync' }, () => {
|
||||
const state = channel.presenceState<PresenceState>()
|
||||
console.log('Online users:', Object.keys(state))
|
||||
})
|
||||
.on('presence', { event: 'join' }, ({ key, newPresences }) => {
|
||||
console.log('User joined:', key, newPresences)
|
||||
})
|
||||
.on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
|
||||
console.log('User left:', key, leftPresences)
|
||||
})
|
||||
.subscribe(async (status) => {
|
||||
if (status === 'SUBSCRIBED') {
|
||||
await channel.track({
|
||||
user_id: userId,
|
||||
online_at: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Update Presence State
|
||||
|
||||
Update user presence in real-time:
|
||||
|
||||
```typescript
|
||||
// Track typing status
|
||||
await channel.track({ typing: true })
|
||||
|
||||
// Track cursor position
|
||||
await channel.track({ cursor: { x: 100, y: 200 } })
|
||||
|
||||
// Clear typing after timeout
|
||||
setTimeout(async () => {
|
||||
await channel.track({ typing: false })
|
||||
}, 1000)
|
||||
```
|
||||
|
||||
## Collaborative Features
|
||||
|
||||
### Collaborative Cursors
|
||||
|
||||
```typescript
|
||||
interface CursorState {
|
||||
user_id: string
|
||||
user_name: string
|
||||
cursor: { x: number; y: number }
|
||||
color: string
|
||||
}
|
||||
|
||||
function setupCollaborativeCursors(documentId: string, userId: string, userName: string) {
|
||||
const channel = supabase.channel(`cursors:${documentId}`, {
|
||||
config: { presence: { key: userId } }
|
||||
})
|
||||
|
||||
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7']
|
||||
const userColor = colors[Math.abs(userId.hashCode()) % colors.length]
|
||||
|
||||
channel
|
||||
.on('presence', { event: 'sync' }, () => {
|
||||
const state = channel.presenceState<CursorState>()
|
||||
renderCursors(Object.values(state).flat())
|
||||
})
|
||||
.subscribe(async (status) => {
|
||||
if (status === 'SUBSCRIBED') {
|
||||
await channel.track({
|
||||
user_id: userId,
|
||||
user_name: userName,
|
||||
cursor: { x: 0, y: 0 },
|
||||
color: userColor
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Track mouse movement
|
||||
document.addEventListener('mousemove', async (e) => {
|
||||
await channel.track({
|
||||
user_id: userId,
|
||||
user_name: userName,
|
||||
cursor: { x: e.clientX, y: e.clientY },
|
||||
color: userColor
|
||||
})
|
||||
})
|
||||
|
||||
return channel
|
||||
}
|
||||
```
|
||||
|
||||
### Live Editing Indicators
|
||||
|
||||
```typescript
|
||||
interface EditingState {
|
||||
user_id: string
|
||||
user_name: string
|
||||
editing_field: string | null
|
||||
}
|
||||
|
||||
function setupFieldLocking(formId: string) {
|
||||
const channel = supabase.channel(`form:${formId}`, {
|
||||
config: { presence: { key: currentUserId } }
|
||||
})
|
||||
|
||||
channel
|
||||
.on('presence', { event: 'sync' }, () => {
|
||||
const state = channel.presenceState<EditingState>()
|
||||
updateFieldLocks(Object.values(state).flat())
|
||||
})
|
||||
.subscribe()
|
||||
|
||||
return {
|
||||
startEditing: async (fieldName: string) => {
|
||||
await channel.track({
|
||||
user_id: currentUserId,
|
||||
user_name: currentUserName,
|
||||
editing_field: fieldName
|
||||
})
|
||||
},
|
||||
stopEditing: async () => {
|
||||
await channel.track({
|
||||
user_id: currentUserId,
|
||||
user_name: currentUserName,
|
||||
editing_field: null
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Broadcast Messages
|
||||
|
||||
Send arbitrary messages to channel subscribers:
|
||||
|
||||
```typescript
|
||||
const channel = supabase.channel('room:chat')
|
||||
|
||||
// Subscribe to broadcasts
|
||||
channel
|
||||
.on('broadcast', { event: 'message' }, ({ payload }) => {
|
||||
console.log('Received:', payload)
|
||||
})
|
||||
.subscribe()
|
||||
|
||||
// Send broadcast
|
||||
await channel.send({
|
||||
type: 'broadcast',
|
||||
event: 'message',
|
||||
payload: { text: 'Hello everyone!', sender: userId }
|
||||
})
|
||||
```
|
||||
|
||||
## Subscription Management
|
||||
|
||||
### Unsubscribe
|
||||
|
||||
```typescript
|
||||
// Unsubscribe from specific channel
|
||||
await supabase.removeChannel(channel)
|
||||
|
||||
// Unsubscribe from all channels
|
||||
await supabase.removeAllChannels()
|
||||
```
|
||||
|
||||
### Subscription Status
|
||||
|
||||
```typescript
|
||||
channel.subscribe((status) => {
|
||||
switch (status) {
|
||||
case 'SUBSCRIBED':
|
||||
console.log('Connected to channel')
|
||||
break
|
||||
case 'CLOSED':
|
||||
console.log('Channel closed')
|
||||
break
|
||||
case 'CHANNEL_ERROR':
|
||||
console.log('Channel error')
|
||||
break
|
||||
case 'TIMED_OUT':
|
||||
console.log('Connection timed out')
|
||||
break
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## React Integration
|
||||
|
||||
### Custom Hook for Presence
|
||||
|
||||
```typescript
|
||||
import { useEffect, useState } from 'react'
|
||||
import { supabase } from './supabase/client'
|
||||
|
||||
export function usePresence<T>(channelName: string, userId: string, initialState: T) {
|
||||
const [presences, setPresences] = useState<Record<string, T[]>>({})
|
||||
|
||||
useEffect(() => {
|
||||
const channel = supabase.channel(channelName, {
|
||||
config: { presence: { key: userId } }
|
||||
})
|
||||
|
||||
channel
|
||||
.on('presence', { event: 'sync' }, () => {
|
||||
setPresences(channel.presenceState<T>())
|
||||
})
|
||||
.subscribe(async (status) => {
|
||||
if (status === 'SUBSCRIBED') {
|
||||
await channel.track(initialState)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel)
|
||||
}
|
||||
}, [channelName, userId])
|
||||
|
||||
const updatePresence = async (state: Partial<T>) => {
|
||||
const channel = supabase.getChannels().find(c => c.topic === channelName)
|
||||
if (channel) {
|
||||
await channel.track({ ...initialState, ...state } as T)
|
||||
}
|
||||
}
|
||||
|
||||
return { presences, updatePresence }
|
||||
}
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```typescript
|
||||
function CollaborativeEditor({ documentId, userId }) {
|
||||
const { presences, updatePresence } = usePresence(
|
||||
`doc:${documentId}`,
|
||||
userId,
|
||||
{ user_id: userId, typing: false, cursor: null }
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{Object.values(presences).flat().map(p => (
|
||||
<Cursor key={p.user_id} position={p.cursor} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Context7 Query Examples
|
||||
|
||||
For latest real-time documentation:
|
||||
|
||||
Topic: "realtime postgres_changes subscription"
|
||||
Topic: "presence tracking channel"
|
||||
Topic: "broadcast messages supabase"
|
||||
|
||||
---
|
||||
|
||||
Related Modules:
|
||||
- typescript-patterns.md - Client architecture
|
||||
- auth-integration.md - Authenticated subscriptions
|
||||
@@ -0,0 +1,286 @@
|
||||
---
|
||||
name: row-level-security
|
||||
description: RLS policies for multi-tenant data isolation and access control
|
||||
parent-skill: moai-platform-supabase
|
||||
version: 1.0.0
|
||||
updated: 2026-01-06
|
||||
---
|
||||
|
||||
# Row-Level Security (RLS) Module
|
||||
|
||||
## Overview
|
||||
|
||||
Row-Level Security provides automatic data isolation at the database level, ensuring users can only access data they are authorized to see.
|
||||
|
||||
## Basic Setup
|
||||
|
||||
Enable RLS on a table:
|
||||
|
||||
```sql
|
||||
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
|
||||
```
|
||||
|
||||
## Policy Types
|
||||
|
||||
RLS policies can be created for specific operations:
|
||||
|
||||
- SELECT: Controls read access
|
||||
- INSERT: Controls creation
|
||||
- UPDATE: Controls modification
|
||||
- DELETE: Controls removal
|
||||
- ALL: Applies to all operations
|
||||
|
||||
## Basic Tenant Isolation
|
||||
|
||||
### JWT-Based Tenant Isolation
|
||||
|
||||
Extract tenant ID from JWT claims:
|
||||
|
||||
```sql
|
||||
CREATE POLICY "tenant_isolation" ON projects FOR ALL
|
||||
USING (tenant_id = (auth.jwt() ->> 'tenant_id')::UUID);
|
||||
```
|
||||
|
||||
### Owner-Based Access
|
||||
|
||||
Restrict access to resource owners:
|
||||
|
||||
```sql
|
||||
CREATE POLICY "owner_access" ON projects FOR ALL
|
||||
USING (owner_id = auth.uid());
|
||||
```
|
||||
|
||||
## Hierarchical Access Patterns
|
||||
|
||||
### Organization Membership
|
||||
|
||||
Allow access based on organization membership:
|
||||
|
||||
```sql
|
||||
CREATE POLICY "org_member_select" ON organizations FOR SELECT
|
||||
USING (id IN (SELECT org_id FROM org_members WHERE user_id = auth.uid()));
|
||||
```
|
||||
|
||||
### Role-Based Modification
|
||||
|
||||
Restrict modifications to specific roles:
|
||||
|
||||
```sql
|
||||
CREATE POLICY "org_admin_modify" ON organizations FOR UPDATE
|
||||
USING (id IN (
|
||||
SELECT org_id FROM org_members
|
||||
WHERE user_id = auth.uid() AND role IN ('owner', 'admin')
|
||||
));
|
||||
```
|
||||
|
||||
### Cascading Project Access
|
||||
|
||||
Grant project access through organization membership:
|
||||
|
||||
```sql
|
||||
CREATE POLICY "project_access" ON projects FOR ALL
|
||||
USING (org_id IN (SELECT org_id FROM org_members WHERE user_id = auth.uid()));
|
||||
```
|
||||
|
||||
## Service Role Bypass
|
||||
|
||||
Allow service role to bypass RLS for server-side operations:
|
||||
|
||||
```sql
|
||||
CREATE POLICY "service_bypass" ON organizations FOR ALL TO service_role USING (true);
|
||||
```
|
||||
|
||||
## Multi-Tenant SaaS Schema
|
||||
|
||||
### Complete Schema Setup
|
||||
|
||||
```sql
|
||||
-- Organizations (tenants)
|
||||
CREATE TABLE organizations (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
plan TEXT DEFAULT 'free' CHECK (plan IN ('free', 'pro', 'enterprise')),
|
||||
settings JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Organization members with roles
|
||||
CREATE TABLE organization_members (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
|
||||
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(organization_id, user_id)
|
||||
);
|
||||
|
||||
-- Projects within organizations
|
||||
CREATE TABLE projects (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
owner_id UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
### Enable RLS on All Tables
|
||||
|
||||
```sql
|
||||
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE organization_members ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
|
||||
```
|
||||
|
||||
### Comprehensive RLS Policies
|
||||
|
||||
```sql
|
||||
-- Organization read access
|
||||
CREATE POLICY "org_member_select" ON organizations FOR SELECT
|
||||
USING (id IN (SELECT organization_id FROM organization_members WHERE user_id = auth.uid()));
|
||||
|
||||
-- Organization admin update
|
||||
CREATE POLICY "org_admin_update" ON organizations FOR UPDATE
|
||||
USING (id IN (SELECT organization_id FROM organization_members
|
||||
WHERE user_id = auth.uid() AND role IN ('owner', 'admin')));
|
||||
|
||||
-- Project member access
|
||||
CREATE POLICY "project_member_access" ON projects FOR ALL
|
||||
USING (organization_id IN (SELECT organization_id FROM organization_members WHERE user_id = auth.uid()));
|
||||
|
||||
-- Member management (admin only)
|
||||
CREATE POLICY "member_admin_manage" ON organization_members FOR ALL
|
||||
USING (organization_id IN (SELECT organization_id FROM organization_members
|
||||
WHERE user_id = auth.uid() AND role IN ('owner', 'admin')));
|
||||
```
|
||||
|
||||
## Helper Functions
|
||||
|
||||
### Check Organization Membership
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION is_org_member(org_id UUID)
|
||||
RETURNS BOOLEAN AS $$
|
||||
BEGIN
|
||||
RETURN EXISTS (
|
||||
SELECT 1 FROM organization_members
|
||||
WHERE organization_id = org_id AND user_id = auth.uid()
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
```
|
||||
|
||||
### Check Organization Role
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION has_org_role(org_id UUID, required_roles TEXT[])
|
||||
RETURNS BOOLEAN AS $$
|
||||
BEGIN
|
||||
RETURN EXISTS (
|
||||
SELECT 1 FROM organization_members
|
||||
WHERE organization_id = org_id
|
||||
AND user_id = auth.uid()
|
||||
AND role = ANY(required_roles)
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
```
|
||||
|
||||
### Usage in Policies
|
||||
|
||||
```sql
|
||||
CREATE POLICY "project_admin_delete" ON projects FOR DELETE
|
||||
USING (has_org_role(organization_id, ARRAY['owner', 'admin']));
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Index for RLS Queries
|
||||
|
||||
Create indexes on foreign keys used in RLS policies:
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_org_members_user ON organization_members(user_id);
|
||||
CREATE INDEX idx_org_members_org ON organization_members(organization_id);
|
||||
CREATE INDEX idx_projects_org ON projects(organization_id);
|
||||
```
|
||||
|
||||
### Materialized View for Complex Policies
|
||||
|
||||
For complex permission checks, use materialized views:
|
||||
|
||||
```sql
|
||||
CREATE MATERIALIZED VIEW user_accessible_projects AS
|
||||
SELECT p.id as project_id, om.user_id, om.role
|
||||
FROM projects p
|
||||
JOIN organization_members om ON p.organization_id = om.organization_id;
|
||||
|
||||
CREATE INDEX idx_uap_user ON user_accessible_projects(user_id);
|
||||
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY user_accessible_projects;
|
||||
```
|
||||
|
||||
## Testing RLS Policies
|
||||
|
||||
### Test as Authenticated User
|
||||
|
||||
```sql
|
||||
SET request.jwt.claim.sub = 'user-uuid-here';
|
||||
SET request.jwt.claims = '{"role": "authenticated"}';
|
||||
|
||||
SELECT * FROM projects; -- Returns only accessible projects
|
||||
```
|
||||
|
||||
### Verify Policy Restrictions
|
||||
|
||||
```sql
|
||||
-- Should fail if not a member
|
||||
INSERT INTO projects (organization_id, name, owner_id)
|
||||
VALUES ('non-member-org-id', 'Test', auth.uid());
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Public Read, Owner Write
|
||||
|
||||
```sql
|
||||
CREATE POLICY "public_read" ON posts FOR SELECT USING (true);
|
||||
CREATE POLICY "owner_write" ON posts FOR INSERT WITH CHECK (author_id = auth.uid());
|
||||
CREATE POLICY "owner_update" ON posts FOR UPDATE USING (author_id = auth.uid());
|
||||
CREATE POLICY "owner_delete" ON posts FOR DELETE USING (author_id = auth.uid());
|
||||
```
|
||||
|
||||
### Draft vs Published
|
||||
|
||||
```sql
|
||||
CREATE POLICY "published_read" ON articles FOR SELECT
|
||||
USING (status = 'published' OR author_id = auth.uid());
|
||||
```
|
||||
|
||||
### Time-Based Access
|
||||
|
||||
```sql
|
||||
CREATE POLICY "active_subscription" ON premium_content FOR SELECT
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM subscriptions
|
||||
WHERE user_id = auth.uid()
|
||||
AND expires_at > NOW()
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
## Context7 Query Examples
|
||||
|
||||
For latest RLS documentation:
|
||||
|
||||
Topic: "row level security policies supabase"
|
||||
Topic: "auth.uid auth.jwt functions"
|
||||
Topic: "rls performance optimization"
|
||||
|
||||
---
|
||||
|
||||
Related Modules:
|
||||
- auth-integration.md - Authentication patterns
|
||||
- typescript-patterns.md - Client-side access patterns
|
||||
@@ -0,0 +1,319 @@
|
||||
---
|
||||
name: storage-cdn
|
||||
description: File storage with image transformations and CDN delivery
|
||||
parent-skill: moai-platform-supabase
|
||||
version: 1.0.0
|
||||
updated: 2026-01-06
|
||||
---
|
||||
|
||||
# Storage and CDN Module
|
||||
|
||||
## Overview
|
||||
|
||||
Supabase Storage provides file storage with automatic image transformations, CDN delivery, and fine-grained access control through storage policies.
|
||||
|
||||
## Basic Upload
|
||||
|
||||
### Upload File
|
||||
|
||||
```typescript
|
||||
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
|
||||
|
||||
async function uploadFile(file: File, bucket: string, path: string) {
|
||||
const { data, error } = await supabase.storage
|
||||
.from(bucket)
|
||||
.upload(path, file, {
|
||||
cacheControl: '3600',
|
||||
upsert: false
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
return data.path
|
||||
}
|
||||
```
|
||||
|
||||
### Upload with User Context
|
||||
|
||||
```typescript
|
||||
async function uploadUserFile(file: File, userId: string) {
|
||||
const fileName = `${userId}/${Date.now()}-${file.name}`
|
||||
|
||||
const { data, error } = await supabase.storage
|
||||
.from('user-files')
|
||||
.upload(fileName, file, {
|
||||
cacheControl: '3600',
|
||||
upsert: false
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
```
|
||||
|
||||
## Image Transformations
|
||||
|
||||
### Get Transformed URL
|
||||
|
||||
```typescript
|
||||
async function uploadImage(file: File, userId: string) {
|
||||
const fileName = `${userId}/${Date.now()}-${file.name}`
|
||||
|
||||
const { data, error } = await supabase.storage
|
||||
.from('images')
|
||||
.upload(fileName, file, { cacheControl: '3600', upsert: false })
|
||||
|
||||
if (error) throw error
|
||||
|
||||
// Get original URL
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from('images')
|
||||
.getPublicUrl(fileName)
|
||||
|
||||
// Get resized URL
|
||||
const { data: { publicUrl: resizedUrl } } = supabase.storage
|
||||
.from('images')
|
||||
.getPublicUrl(fileName, {
|
||||
transform: { width: 800, height: 600, resize: 'contain' }
|
||||
})
|
||||
|
||||
// Get thumbnail URL
|
||||
const { data: { publicUrl: thumbnailUrl } } = supabase.storage
|
||||
.from('images')
|
||||
.getPublicUrl(fileName, {
|
||||
transform: { width: 200, height: 200, resize: 'cover' }
|
||||
})
|
||||
|
||||
return { originalPath: data.path, publicUrl, resizedUrl, thumbnailUrl }
|
||||
}
|
||||
```
|
||||
|
||||
### Transform Options
|
||||
|
||||
Available transformation parameters:
|
||||
|
||||
- width: Target width in pixels
|
||||
- height: Target height in pixels
|
||||
- resize: 'cover' | 'contain' | 'fill'
|
||||
- format: 'origin' | 'avif' | 'webp'
|
||||
- quality: 1-100
|
||||
|
||||
### Example Transforms
|
||||
|
||||
```typescript
|
||||
// Square thumbnail with crop
|
||||
const thumbnail = supabase.storage
|
||||
.from('images')
|
||||
.getPublicUrl(path, {
|
||||
transform: { width: 150, height: 150, resize: 'cover' }
|
||||
})
|
||||
|
||||
// WebP format for smaller size
|
||||
const webp = supabase.storage
|
||||
.from('images')
|
||||
.getPublicUrl(path, {
|
||||
transform: { width: 800, format: 'webp', quality: 80 }
|
||||
})
|
||||
|
||||
// Responsive image
|
||||
const responsive = supabase.storage
|
||||
.from('images')
|
||||
.getPublicUrl(path, {
|
||||
transform: { width: 400, resize: 'contain' }
|
||||
})
|
||||
```
|
||||
|
||||
## Bucket Management
|
||||
|
||||
### Create Bucket
|
||||
|
||||
```sql
|
||||
-- Via SQL
|
||||
INSERT INTO storage.buckets (id, name, public)
|
||||
VALUES ('images', 'images', true);
|
||||
```
|
||||
|
||||
### Bucket Policies
|
||||
|
||||
```sql
|
||||
-- Allow authenticated users to upload to their folder
|
||||
CREATE POLICY "User upload" ON storage.objects
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (bucket_id = 'user-files' AND (storage.foldername(name))[1] = auth.uid()::text);
|
||||
|
||||
-- Allow public read on images bucket
|
||||
CREATE POLICY "Public read" ON storage.objects
|
||||
FOR SELECT
|
||||
TO public
|
||||
USING (bucket_id = 'images');
|
||||
|
||||
-- Allow users to delete their own files
|
||||
CREATE POLICY "User delete" ON storage.objects
|
||||
FOR DELETE
|
||||
TO authenticated
|
||||
USING (bucket_id = 'user-files' AND (storage.foldername(name))[1] = auth.uid()::text);
|
||||
```
|
||||
|
||||
## Download Files
|
||||
|
||||
### Get Signed URL
|
||||
|
||||
For private buckets:
|
||||
|
||||
```typescript
|
||||
async function getSignedUrl(bucket: string, path: string, expiresIn: number = 3600) {
|
||||
const { data, error } = await supabase.storage
|
||||
.from(bucket)
|
||||
.createSignedUrl(path, expiresIn)
|
||||
|
||||
if (error) throw error
|
||||
return data.signedUrl
|
||||
}
|
||||
```
|
||||
|
||||
### Download File
|
||||
|
||||
```typescript
|
||||
async function downloadFile(bucket: string, path: string) {
|
||||
const { data, error } = await supabase.storage
|
||||
.from(bucket)
|
||||
.download(path)
|
||||
|
||||
if (error) throw error
|
||||
return data // Blob
|
||||
}
|
||||
```
|
||||
|
||||
## File Management
|
||||
|
||||
### List Files
|
||||
|
||||
```typescript
|
||||
async function listFiles(bucket: string, folder: string) {
|
||||
const { data, error } = await supabase.storage
|
||||
.from(bucket)
|
||||
.list(folder, {
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
sortBy: { column: 'created_at', order: 'desc' }
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
```
|
||||
|
||||
### Delete File
|
||||
|
||||
```typescript
|
||||
async function deleteFile(bucket: string, paths: string[]) {
|
||||
const { data, error } = await supabase.storage
|
||||
.from(bucket)
|
||||
.remove(paths)
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
```
|
||||
|
||||
### Move/Rename File
|
||||
|
||||
```typescript
|
||||
async function moveFile(bucket: string, fromPath: string, toPath: string) {
|
||||
const { data, error } = await supabase.storage
|
||||
.from(bucket)
|
||||
.move(fromPath, toPath)
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
```
|
||||
|
||||
## React Integration
|
||||
|
||||
### Upload Component
|
||||
|
||||
```typescript
|
||||
function FileUploader({ bucket, onUpload }: Props) {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
async function handleUpload(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setUploading(true)
|
||||
try {
|
||||
const path = `${Date.now()}-${file.name}`
|
||||
const { error } = await supabase.storage
|
||||
.from(bucket)
|
||||
.upload(path, file)
|
||||
|
||||
if (error) throw error
|
||||
onUpload(path)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
type="file"
|
||||
onChange={handleUpload}
|
||||
disabled={uploading}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Image with Fallback
|
||||
|
||||
```typescript
|
||||
function StorageImage({ path, bucket, width, height, fallback }: Props) {
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from(bucket)
|
||||
.getPublicUrl(path, {
|
||||
transform: { width, height, resize: 'cover' }
|
||||
})
|
||||
|
||||
return (
|
||||
<img
|
||||
src={publicUrl}
|
||||
alt=""
|
||||
onError={(e) => { e.currentTarget.src = fallback }}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
File Organization:
|
||||
- Use user ID as folder prefix for user content
|
||||
- Include timestamp in filenames to prevent collisions
|
||||
- Use consistent naming conventions
|
||||
|
||||
Performance:
|
||||
- Set appropriate cache-control headers
|
||||
- Use image transformations instead of storing multiple sizes
|
||||
- Leverage CDN for global delivery
|
||||
|
||||
Security:
|
||||
- Always use RLS-style policies for storage
|
||||
- Use signed URLs for private content
|
||||
- Validate file types before upload
|
||||
|
||||
## Context7 Query Examples
|
||||
|
||||
For latest Storage documentation:
|
||||
|
||||
Topic: "supabase storage upload download"
|
||||
Topic: "storage image transformations"
|
||||
Topic: "storage bucket policies"
|
||||
|
||||
---
|
||||
|
||||
Related Modules:
|
||||
- row-level-security.md - Storage access policies
|
||||
- typescript-patterns.md - Client patterns
|
||||
@@ -0,0 +1,453 @@
|
||||
---
|
||||
name: typescript-patterns
|
||||
description: TypeScript client patterns and service layer architecture for Supabase
|
||||
parent-skill: moai-platform-supabase
|
||||
version: 1.0.0
|
||||
updated: 2026-01-06
|
||||
---
|
||||
|
||||
# TypeScript Patterns Module
|
||||
|
||||
## Overview
|
||||
|
||||
Type-safe Supabase client patterns for building maintainable full-stack applications with TypeScript.
|
||||
|
||||
## Type Generation
|
||||
|
||||
### Generate Types from Database
|
||||
|
||||
```bash
|
||||
supabase gen types typescript --project-id your-project-id > database.types.ts
|
||||
```
|
||||
|
||||
### Database Types Structure
|
||||
|
||||
```typescript
|
||||
// database.types.ts
|
||||
export type Database = {
|
||||
public: {
|
||||
Tables: {
|
||||
projects: {
|
||||
Row: {
|
||||
id: string
|
||||
name: string
|
||||
organization_id: string
|
||||
owner_id: string
|
||||
created_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
name: string
|
||||
organization_id: string
|
||||
owner_id: string
|
||||
created_at?: string
|
||||
}
|
||||
Update: {
|
||||
id?: string
|
||||
name?: string
|
||||
organization_id?: string
|
||||
owner_id?: string
|
||||
created_at?: string
|
||||
}
|
||||
}
|
||||
// ... other tables
|
||||
}
|
||||
Functions: {
|
||||
search_documents: {
|
||||
Args: {
|
||||
query_embedding: number[]
|
||||
match_threshold: number
|
||||
match_count: number
|
||||
}
|
||||
Returns: { id: string; content: string; similarity: number }[]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Client Configuration
|
||||
|
||||
### Browser Client with Types
|
||||
|
||||
```typescript
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { Database } from './database.types'
|
||||
|
||||
export const supabase = createClient<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
)
|
||||
```
|
||||
|
||||
### Server Client (Next.js App Router)
|
||||
|
||||
```typescript
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { cookies } from 'next/headers'
|
||||
import { Database } from './database.types'
|
||||
|
||||
export function createServerSupabase() {
|
||||
const cookieStore = cookies()
|
||||
|
||||
return createServerClient<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
get(name: string) {
|
||||
return cookieStore.get(name)?.value
|
||||
},
|
||||
set(name, value, options) {
|
||||
cookieStore.set({ name, value, ...options })
|
||||
},
|
||||
remove(name, options) {
|
||||
cookieStore.set({ name, value: '', ...options })
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Service Layer Pattern
|
||||
|
||||
### Base Service
|
||||
|
||||
```typescript
|
||||
import { supabase } from './supabase/client'
|
||||
import { Database } from './database.types'
|
||||
|
||||
type Tables = Database['public']['Tables']
|
||||
|
||||
export abstract class BaseService<T extends keyof Tables> {
|
||||
constructor(protected tableName: T) {}
|
||||
|
||||
async findAll() {
|
||||
const { data, error } = await supabase
|
||||
.from(this.tableName)
|
||||
.select('*')
|
||||
|
||||
if (error) throw error
|
||||
return data as Tables[T]['Row'][]
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
const { data, error } = await supabase
|
||||
.from(this.tableName)
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data as Tables[T]['Row']
|
||||
}
|
||||
|
||||
async create(item: Tables[T]['Insert']) {
|
||||
const { data, error } = await supabase
|
||||
.from(this.tableName)
|
||||
.insert(item)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data as Tables[T]['Row']
|
||||
}
|
||||
|
||||
async update(id: string, item: Tables[T]['Update']) {
|
||||
const { data, error } = await supabase
|
||||
.from(this.tableName)
|
||||
.update(item)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data as Tables[T]['Row']
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
const { error } = await supabase
|
||||
.from(this.tableName)
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Document Service with Embeddings
|
||||
|
||||
```typescript
|
||||
import { supabase } from './supabase/client'
|
||||
|
||||
export class DocumentService {
|
||||
async create(projectId: string, title: string, content: string) {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('documents')
|
||||
.insert({
|
||||
project_id: projectId,
|
||||
title,
|
||||
content,
|
||||
created_by: user!.id
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
|
||||
// Generate embedding asynchronously
|
||||
await supabase.functions.invoke('generate-embedding', {
|
||||
body: { documentId: data.id, content }
|
||||
})
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
async semanticSearch(projectId: string, query: string) {
|
||||
// Get embedding for query
|
||||
const { data: embeddingData } = await supabase.functions.invoke(
|
||||
'get-embedding',
|
||||
{ body: { text: query } }
|
||||
)
|
||||
|
||||
// Search using RPC
|
||||
const { data, error } = await supabase.rpc('search_documents', {
|
||||
p_project_id: projectId,
|
||||
p_query_embedding: embeddingData.embedding,
|
||||
p_match_threshold: 0.7,
|
||||
p_match_count: 10
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
async findByProject(projectId: string) {
|
||||
const { data, error } = await supabase
|
||||
.from('documents')
|
||||
.select('*, created_by_user:profiles!created_by(*)')
|
||||
.eq('project_id', projectId)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
subscribeToChanges(projectId: string, callback: (payload: any) => void) {
|
||||
return supabase.channel(`documents:${projectId}`)
|
||||
.on('postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'documents',
|
||||
filter: `project_id=eq.${projectId}`
|
||||
},
|
||||
callback
|
||||
)
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
export const documentService = new DocumentService()
|
||||
```
|
||||
|
||||
## React Query Integration
|
||||
|
||||
### Query Keys
|
||||
|
||||
```typescript
|
||||
export const queryKeys = {
|
||||
projects: {
|
||||
all: ['projects'] as const,
|
||||
list: (filters?: ProjectFilters) => [...queryKeys.projects.all, 'list', filters] as const,
|
||||
detail: (id: string) => [...queryKeys.projects.all, 'detail', id] as const
|
||||
},
|
||||
documents: {
|
||||
all: ['documents'] as const,
|
||||
list: (projectId: string) => [...queryKeys.documents.all, 'list', projectId] as const,
|
||||
search: (projectId: string, query: string) =>
|
||||
[...queryKeys.documents.all, 'search', projectId, query] as const
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Hooks
|
||||
|
||||
```typescript
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { documentService } from '@/services/document-service'
|
||||
|
||||
export function useDocuments(projectId: string) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.documents.list(projectId),
|
||||
queryFn: () => documentService.findByProject(projectId)
|
||||
})
|
||||
}
|
||||
|
||||
export function useSemanticSearch(projectId: string, query: string) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.documents.search(projectId, query),
|
||||
queryFn: () => documentService.semanticSearch(projectId, query),
|
||||
enabled: query.length > 2
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateDocument(projectId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ title, content }: { title: string; content: string }) =>
|
||||
documentService.create(projectId, title, content),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.documents.list(projectId)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Real-time with React
|
||||
|
||||
### Subscription Hook
|
||||
|
||||
```typescript
|
||||
import { useEffect } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
export function useRealtimeDocuments(projectId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
useEffect(() => {
|
||||
const channel = supabase
|
||||
.channel(`documents:${projectId}`)
|
||||
.on('postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'documents',
|
||||
filter: `project_id=eq.${projectId}`
|
||||
},
|
||||
() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.documents.list(projectId)
|
||||
})
|
||||
}
|
||||
)
|
||||
.subscribe()
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel)
|
||||
}
|
||||
}, [projectId, queryClient])
|
||||
}
|
||||
```
|
||||
|
||||
### Optimistic Updates
|
||||
|
||||
```typescript
|
||||
export function useUpdateDocument() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, updates }: { id: string; updates: Partial<Document> }) => {
|
||||
const { data, error } = await supabase
|
||||
.from('documents')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
onMutate: async ({ id, updates }) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['documents'] })
|
||||
|
||||
const previousDocuments = queryClient.getQueryData(['documents'])
|
||||
|
||||
queryClient.setQueryData(['documents'], (old: Document[]) =>
|
||||
old.map(doc => doc.id === id ? { ...doc, ...updates } : doc)
|
||||
)
|
||||
|
||||
return { previousDocuments }
|
||||
},
|
||||
onError: (err, variables, context) => {
|
||||
if (context?.previousDocuments) {
|
||||
queryClient.setQueryData(['documents'], context.previousDocuments)
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['documents'] })
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Custom Error Types
|
||||
|
||||
```typescript
|
||||
export class SupabaseError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public details?: unknown
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'SupabaseError'
|
||||
}
|
||||
}
|
||||
|
||||
export function handleSupabaseError(error: PostgrestError): never {
|
||||
switch (error.code) {
|
||||
case '23505':
|
||||
throw new SupabaseError('Resource already exists', 'DUPLICATE', error)
|
||||
case '23503':
|
||||
throw new SupabaseError('Referenced resource not found', 'NOT_FOUND', error)
|
||||
case 'PGRST116':
|
||||
throw new SupabaseError('Resource not found', 'NOT_FOUND', error)
|
||||
default:
|
||||
throw new SupabaseError(error.message, error.code, error)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Service with Error Handling
|
||||
|
||||
```typescript
|
||||
async findById(id: string) {
|
||||
const { data, error } = await supabase
|
||||
.from(this.tableName)
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
handleSupabaseError(error)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
```
|
||||
|
||||
## Context7 Query Examples
|
||||
|
||||
For latest client documentation:
|
||||
|
||||
Topic: "supabase-js typescript client"
|
||||
Topic: "supabase ssr next.js app router"
|
||||
Topic: "supabase realtime subscription"
|
||||
|
||||
---
|
||||
|
||||
Related Modules:
|
||||
- auth-integration.md - Auth patterns
|
||||
- realtime-presence.md - Real-time subscriptions
|
||||
- postgresql-pgvector.md - Database operations
|
||||
Reference in New Issue
Block a user