Next.js Supabase V3 (#463)
Version 3 of the kit: - Radix UI replaced with Base UI (using the Shadcn UI patterns) - next-intl replaces react-i18next - enhanceAction deprecated; usage moved to next-safe-action - main layout now wrapped with [locale] path segment - Teams only mode - Layout updates - Zod v4 - Next.js 16.2 - Typescript 6 - All other dependencies updated - Removed deprecated Edge CSRF - Dynamic Github Action runner
This commit is contained in:
committed by
GitHub
parent
4912e402a3
commit
7ebff31475
542
docs/development/database-schema.mdoc
Normal file
542
docs/development/database-schema.mdoc
Normal file
@@ -0,0 +1,542 @@
|
||||
---
|
||||
status: "published"
|
||||
label: "Extending the DB Schema"
|
||||
order: 2
|
||||
title: "Extending the Database Schema in Next.js Supabase"
|
||||
description: "Learn how to create new migrations and update the database schema in your Next.js Supabase application"
|
||||
---
|
||||
|
||||
{% sequence title="Steps to create a new migration" description="Learn how to create new migrations and update the database schema in your Next.js Supabase application" %}
|
||||
|
||||
[Planning Your Schema Extension](#planning-your-schema-extension)
|
||||
|
||||
[Creating Schema Files](#creating-schema-files)
|
||||
|
||||
[Permissions and Access Control](#permissions-and-access-control)
|
||||
|
||||
[Building Tables with RLS](#building-tables-with-rls)
|
||||
|
||||
[Advanced Patterns](#advanced-patterns)
|
||||
|
||||
[Testing and Deployment](#testing-and-deployment)
|
||||
|
||||
{% /sequence %}
|
||||
|
||||
This guide walks you through extending Makerkit's database schema with new tables and features. We'll use a comprehensive example that demonstrates best practices, security patterns, and integration with Makerkit's multi-tenant architecture.
|
||||
|
||||
## Planning Your Schema Extension
|
||||
|
||||
Before writing any SQL, it's crucial to understand how your new features fit into Makerkit's multi-tenant architecture.
|
||||
|
||||
### Decision Framework
|
||||
|
||||
**Step 1: Determine Data Ownership**
|
||||
Ask yourself: "Who owns this data - individual users or accounts?"
|
||||
|
||||
- **User-owned data**: Personal preferences, activity logs, user settings
|
||||
- **Account-owned data**: Business content, shared resources, collaborative features
|
||||
|
||||
**Step 2: Define Access Patterns**
|
||||
- **Public within account**: All team members can access
|
||||
- **Private within account**: Only creator + specific permissions
|
||||
- **Admin-only**: Requires special permissions or super admin access
|
||||
|
||||
**Step 3: Consider Integration Points**
|
||||
- Does this feature affect billing? (usage tracking, feature gates)
|
||||
- Does it need notifications? (in-app alerts, email triggers)
|
||||
- Should it have audit trails? (compliance, change tracking)
|
||||
|
||||
## Creating Schema Files
|
||||
|
||||
Makerkit organizes database schema in numbered files for proper ordering. Follow this workflow:
|
||||
|
||||
### 1. Create Your Schema File
|
||||
|
||||
```bash
|
||||
# Create a new schema file with the next number
|
||||
touch apps/web/supabase/schemas/18-notes-feature.sql
|
||||
```
|
||||
|
||||
### 2. Apply Development Workflow
|
||||
|
||||
```bash
|
||||
# Start Supabase
|
||||
pnpm supabase:web:start
|
||||
|
||||
# Create migration from your schema file
|
||||
pnpm --filter web run supabase:db:diff -f notes-feature
|
||||
|
||||
# Restart with new schema
|
||||
pnpm supabase:web:reset
|
||||
|
||||
# Generate TypeScript types
|
||||
pnpm supabase:web:typegen
|
||||
```
|
||||
|
||||
## Permissions and Access Control
|
||||
|
||||
### Adding New Permissions
|
||||
|
||||
Makerkit defines permissions in the `public.app_permissions` enum. Add feature-specific permissions:
|
||||
|
||||
```sql
|
||||
-- Add new permissions for your feature
|
||||
ALTER TYPE public.app_permissions ADD VALUE 'notes.create';
|
||||
ALTER TYPE public.app_permissions ADD VALUE 'notes.manage';
|
||||
ALTER TYPE public.app_permissions ADD VALUE 'notes.delete';
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
**Note:** The Supabase diff function does not support adding new permissions to enum types. Please add the new permissions manually instead of using the diff function.
|
||||
|
||||
**Permission Naming Convention**: Use the pattern `resource.action` for consistency:
|
||||
- `notes.create` - Create new notes
|
||||
- `notes.manage` - Edit existing notes
|
||||
- `notes.delete` - Delete notes
|
||||
- `notes.share` - Share with external users
|
||||
|
||||
### Role Assignment
|
||||
|
||||
Consider which roles should have which permissions by default:
|
||||
|
||||
```sql
|
||||
-- Grant permissions to roles
|
||||
INSERT INTO public.role_permissions (role, permission) VALUES
|
||||
('owner', 'notes.create'),
|
||||
('owner', 'notes.manage'),
|
||||
('owner', 'notes.delete'),
|
||||
('owner', 'notes.share'),
|
||||
('member', 'notes.create'),
|
||||
('member', 'notes.manage');
|
||||
```
|
||||
|
||||
## Building Tables with RLS
|
||||
|
||||
Let's create a comprehensive notes feature that demonstrates various patterns and best practices.
|
||||
|
||||
### Core Notes Table
|
||||
|
||||
```sql
|
||||
-- Create the main notes table with all standard fields
|
||||
CREATE TABLE IF NOT EXISTS public.notes (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||
title varchar(500) NOT NULL,
|
||||
content text,
|
||||
is_published boolean NOT NULL DEFAULT false,
|
||||
tags text[] DEFAULT '{}',
|
||||
metadata jsonb DEFAULT '{}',
|
||||
|
||||
-- Audit fields (always include these)
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_by uuid REFERENCES auth.users(id),
|
||||
updated_by uuid REFERENCES auth.users(id),
|
||||
|
||||
-- Data integrity constraints
|
||||
CONSTRAINT notes_title_length CHECK (length(title) >= 1),
|
||||
CONSTRAINT notes_account_required CHECK (account_id IS NOT NULL)
|
||||
);
|
||||
|
||||
-- Add helpful comments for documentation
|
||||
COMMENT ON TABLE public.notes IS 'User-generated notes with sharing capabilities';
|
||||
COMMENT ON COLUMN public.notes.account_id IS 'Account that owns this note (multi-tenant isolation)';
|
||||
COMMENT ON COLUMN public.notes.is_published IS 'Whether note is visible to all account members';
|
||||
COMMENT ON COLUMN public.notes.tags IS 'Searchable tags for categorization';
|
||||
COMMENT ON COLUMN public.notes.metadata IS 'Flexible metadata (view preferences, etc.)';
|
||||
```
|
||||
|
||||
### Performance Indexes
|
||||
|
||||
Consider creating indexes for your query patterns if you are scaling to a large number of records.
|
||||
|
||||
```sql
|
||||
-- Essential indexes for performance
|
||||
CREATE INDEX idx_notes_account_id ON public.notes(account_id);
|
||||
CREATE INDEX idx_notes_created_at ON public.notes(created_at DESC);
|
||||
CREATE INDEX idx_notes_account_created ON public.notes(account_id, created_at DESC);
|
||||
CREATE INDEX idx_notes_published ON public.notes(account_id, is_published) WHERE is_published = true;
|
||||
CREATE INDEX idx_notes_tags ON public.notes USING gin(tags);
|
||||
```
|
||||
|
||||
### Security Setup
|
||||
|
||||
```sql
|
||||
-- Always enable RLS (NEVER skip this!)
|
||||
ALTER TABLE public.notes ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Revoke default permissions and grant explicitly
|
||||
REVOKE ALL ON public.notes FROM authenticated, service_role;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.notes TO authenticated, service_role;
|
||||
```
|
||||
|
||||
### RLS Policies
|
||||
|
||||
Create comprehensive policies that handle both personal and team accounts:
|
||||
|
||||
```sql
|
||||
-- SELECT policy: Read published notes or own private notes
|
||||
CREATE POLICY "notes_select" ON public.notes
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
-- Personal account: direct ownership
|
||||
account_id = (SELECT auth.uid())
|
||||
OR
|
||||
-- Team account: member can read published notes
|
||||
(public.has_role_on_account(account_id) AND is_published = true)
|
||||
OR
|
||||
-- Team account: creator can read their own drafts
|
||||
(public.has_role_on_account(account_id) AND created_by = auth.uid())
|
||||
OR
|
||||
-- Team account: users with manage permission can read all
|
||||
public.has_permission(auth.uid(), account_id, 'notes.manage')
|
||||
);
|
||||
|
||||
-- INSERT policy: Must have create permission
|
||||
CREATE POLICY "notes_insert" ON public.notes
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
-- Personal account: direct ownership
|
||||
account_id = (SELECT auth.uid())
|
||||
OR
|
||||
-- Team account: must have create permission
|
||||
public.has_permission(auth.uid(), account_id, 'notes.create')
|
||||
);
|
||||
|
||||
-- UPDATE policy: Owner or manager can edit
|
||||
CREATE POLICY "notes_update" ON public.notes
|
||||
FOR UPDATE TO authenticated
|
||||
USING (
|
||||
-- Personal account: direct ownership
|
||||
account_id = (SELECT auth.uid())
|
||||
OR
|
||||
-- Team account: creator can edit their own
|
||||
(public.has_role_on_account(account_id) AND created_by = auth.uid())
|
||||
OR
|
||||
-- Team account: users with manage permission
|
||||
public.has_permission(auth.uid(), account_id, 'notes.manage')
|
||||
)
|
||||
WITH CHECK (
|
||||
-- Same conditions for updates
|
||||
account_id = (SELECT auth.uid())
|
||||
OR
|
||||
(public.has_role_on_account(account_id) AND created_by = auth.uid())
|
||||
OR
|
||||
public.has_permission(auth.uid(), account_id, 'notes.manage')
|
||||
);
|
||||
|
||||
-- DELETE policy: Stricter permissions required
|
||||
CREATE POLICY "notes_delete" ON public.notes
|
||||
FOR DELETE TO authenticated
|
||||
USING (
|
||||
-- Personal account: direct ownership
|
||||
account_id = (SELECT auth.uid())
|
||||
OR
|
||||
-- Team account: creator can delete own notes
|
||||
(public.has_role_on_account(account_id) AND created_by = auth.uid())
|
||||
OR
|
||||
-- Team account: users with delete permission
|
||||
public.has_permission(auth.uid(), account_id, 'notes.delete')
|
||||
);
|
||||
```
|
||||
|
||||
### Automatic Triggers
|
||||
|
||||
Add triggers for common patterns:
|
||||
|
||||
```sql
|
||||
-- Automatically update timestamps
|
||||
CREATE TRIGGER notes_updated_at
|
||||
BEFORE UPDATE ON public.notes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION kit.trigger_set_timestamps();
|
||||
|
||||
-- Track who made changes
|
||||
CREATE TRIGGER notes_track_changes
|
||||
BEFORE INSERT OR UPDATE ON public.notes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION kit.trigger_set_user_tracking();
|
||||
```
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### 1. Hierarchical Notes (Categories)
|
||||
|
||||
```sql
|
||||
-- Note categories with hierarchy
|
||||
CREATE TABLE IF NOT EXISTS public.note_categories (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||
parent_id uuid REFERENCES public.note_categories(id) ON DELETE CASCADE,
|
||||
name varchar(255) NOT NULL,
|
||||
color varchar(7), -- hex color codes
|
||||
path ltree, -- efficient tree operations
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_by uuid REFERENCES auth.users(id),
|
||||
|
||||
-- Ensure hierarchy stays within account
|
||||
CONSTRAINT categories_same_account CHECK (
|
||||
parent_id IS NULL OR
|
||||
(SELECT account_id FROM public.note_categories WHERE id = parent_id) = account_id
|
||||
),
|
||||
|
||||
-- Prevent circular references
|
||||
CONSTRAINT categories_no_self_parent CHECK (id != parent_id)
|
||||
);
|
||||
|
||||
-- Link notes to categories
|
||||
ALTER TABLE public.notes ADD COLUMN category_id uuid REFERENCES public.note_categories(id) ON DELETE SET NULL;
|
||||
|
||||
-- Index for tree operations
|
||||
CREATE INDEX idx_note_categories_path ON public.note_categories USING gist(path);
|
||||
CREATE INDEX idx_note_categories_account ON public.note_categories(account_id, parent_id);
|
||||
```
|
||||
|
||||
### 2. Note Sharing and Collaboration
|
||||
|
||||
```sql
|
||||
-- External sharing tokens
|
||||
CREATE TABLE IF NOT EXISTS public.note_shares (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
note_id uuid NOT NULL REFERENCES public.notes(id) ON DELETE CASCADE,
|
||||
share_token varchar(64) NOT NULL UNIQUE,
|
||||
expires_at timestamptz,
|
||||
password_hash varchar(255), -- optional password protection
|
||||
view_count integer DEFAULT 0,
|
||||
max_views integer, -- optional view limit
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_by uuid REFERENCES auth.users(id),
|
||||
|
||||
-- Ensure token uniqueness
|
||||
CONSTRAINT share_token_format CHECK (share_token ~ '^[a-zA-Z0-9_-]{32,64}$')
|
||||
);
|
||||
|
||||
-- Function to generate secure share tokens
|
||||
CREATE OR REPLACE FUNCTION generate_note_share_token()
|
||||
RETURNS varchar(64) AS $$
|
||||
BEGIN
|
||||
RETURN encode(gen_random_bytes(32), 'base64url');
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
```
|
||||
|
||||
### 3. Usage Tracking for Billing
|
||||
|
||||
```sql
|
||||
-- Track note creation for usage-based billing
|
||||
CREATE TABLE IF NOT EXISTS public.note_usage_logs (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||
action varchar(50) NOT NULL, -- 'create', 'share', 'export'
|
||||
note_count integer DEFAULT 1,
|
||||
date date DEFAULT CURRENT_DATE,
|
||||
|
||||
-- Daily aggregation
|
||||
UNIQUE(account_id, action, date)
|
||||
);
|
||||
|
||||
-- Function to track note usage
|
||||
CREATE OR REPLACE FUNCTION track_note_usage(
|
||||
target_account_id uuid,
|
||||
usage_action varchar(50)
|
||||
) RETURNS void AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.note_usage_logs (account_id, action, note_count)
|
||||
VALUES (target_account_id, usage_action, 1)
|
||||
ON CONFLICT (account_id, action, date)
|
||||
DO UPDATE SET note_count = note_usage_logs.note_count + 1;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Trigger to track note creation
|
||||
CREATE OR REPLACE FUNCTION trigger_track_note_creation()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
PERFORM track_note_usage(NEW.account_id, 'create');
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER notes_track_creation
|
||||
AFTER INSERT ON public.notes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trigger_track_note_creation();
|
||||
```
|
||||
|
||||
### 4. Feature Access Control
|
||||
|
||||
```sql
|
||||
-- Check if account has access to advanced note features
|
||||
CREATE OR REPLACE FUNCTION has_advanced_notes_access(target_account_id uuid)
|
||||
RETURNS boolean AS $$
|
||||
DECLARE
|
||||
has_access boolean := false;
|
||||
BEGIN
|
||||
-- Check active subscription with advanced features
|
||||
SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM public.subscriptions s
|
||||
JOIN public.subscription_items si ON s.id = si.subscription_id
|
||||
WHERE s.account_id = target_account_id
|
||||
AND s.status = 'active'
|
||||
AND si.price_id IN ('price_pro_plan', 'price_enterprise_plan')
|
||||
) INTO has_access;
|
||||
|
||||
RETURN has_access;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Restrictive policy for advanced features
|
||||
CREATE POLICY "notes_advanced_features" ON public.notes
|
||||
AS RESTRICTIVE
|
||||
FOR ALL TO authenticated
|
||||
USING (
|
||||
-- Basic features always allowed
|
||||
is_published = true
|
||||
OR category_id IS NULL
|
||||
OR tags = '{}'
|
||||
OR
|
||||
-- Advanced features require subscription
|
||||
has_advanced_notes_access(account_id)
|
||||
);
|
||||
```
|
||||
|
||||
## Security Enhancements
|
||||
|
||||
### MFA Compliance
|
||||
|
||||
For sensitive note operations, enforce MFA:
|
||||
|
||||
```sql
|
||||
-- Require MFA for note deletion
|
||||
CREATE POLICY "notes_delete_mfa" ON public.notes
|
||||
AS RESTRICTIVE
|
||||
FOR DELETE TO authenticated
|
||||
USING (public.is_mfa_compliant());
|
||||
```
|
||||
|
||||
### Super Admin Access
|
||||
|
||||
Allow super admins to access all notes for support purposes:
|
||||
|
||||
```sql
|
||||
-- Super admin read access (for support)
|
||||
CREATE POLICY "notes_super_admin_access" ON public.notes
|
||||
FOR SELECT TO authenticated
|
||||
USING (public.is_super_admin());
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Implement basic rate limiting for note creation:
|
||||
|
||||
```sql
|
||||
-- Rate limiting: max 100 notes per day per account
|
||||
CREATE OR REPLACE FUNCTION check_note_creation_limit(target_account_id uuid)
|
||||
RETURNS boolean AS $$
|
||||
DECLARE
|
||||
daily_count integer;
|
||||
BEGIN
|
||||
SELECT COALESCE(note_count, 0) INTO daily_count
|
||||
FROM public.note_usage_logs
|
||||
WHERE account_id = target_account_id
|
||||
AND action = 'create'
|
||||
AND date = CURRENT_DATE;
|
||||
|
||||
RETURN daily_count < 100; -- Adjust limit as needed
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Policy to enforce rate limiting
|
||||
CREATE POLICY "notes_rate_limit" ON public.notes
|
||||
AS RESTRICTIVE
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (check_note_creation_limit(account_id));
|
||||
```
|
||||
|
||||
### Type Generation
|
||||
|
||||
After schema changes, always update TypeScript types:
|
||||
|
||||
```bash
|
||||
# reset the database
|
||||
pnpm supabase:web:reset
|
||||
|
||||
# Generate new types
|
||||
pnpm supabase:web:typegen
|
||||
|
||||
# Verify types work in your application
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
## Example Usage in Application
|
||||
|
||||
With your schema complete, here's how to use it in your application:
|
||||
|
||||
```typescript
|
||||
// Server component - automatically inherits RLS protection
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
async function NotesPage({ params }: { params: Promise<{ account: string }> }) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
// RLS automatically filters to accessible notes
|
||||
const { data: notes } = await client
|
||||
.from('notes')
|
||||
.select(`
|
||||
*,
|
||||
category:note_categories(name, color),
|
||||
creator:created_by(name, avatar_url)
|
||||
`)
|
||||
.eq('account_id', params.account)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
return <NotesList notes={notes} />;
|
||||
}
|
||||
```
|
||||
|
||||
From the client component, you can use the `useQuery` hook to fetch the notes.
|
||||
|
||||
```typescript
|
||||
// Client component with real-time updates
|
||||
'use client';
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
|
||||
function useNotes(accountId: string) {
|
||||
const supabase = useSupabase();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['notes', accountId],
|
||||
queryFn: async () => {
|
||||
const { data } = await supabase
|
||||
.from('notes')
|
||||
.select('*, category:note_categories(name)')
|
||||
.eq('account_id', accountId);
|
||||
return data;
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
You've now created a comprehensive notes feature that demonstrates:
|
||||
|
||||
✅ **Proper multi-tenancy** with account-based data isolation
|
||||
✅ **Granular permissions** using Makerkit's role system
|
||||
✅ **Advanced features** like categories, sharing, and usage tracking
|
||||
✅ **Security best practices** with comprehensive RLS policies
|
||||
✅ **Performance optimization** with proper indexing
|
||||
✅ **Integration patterns** with billing and feature gates
|
||||
|
||||
This pattern can be adapted for any feature in your SaaS application. Remember to always:
|
||||
- Start with proper planning and data ownership decisions
|
||||
- Enable RLS and create comprehensive policies
|
||||
- Add appropriate indexes for your query patterns
|
||||
- Test thoroughly before deploying
|
||||
- Update TypeScript types after schema changes
|
||||
|
||||
Your database schema is now production-ready and follows Makerkit's security and architecture best practices!
|
||||
Reference in New Issue
Block a user