Modh
AboutServicesWorkPlaybookResourcesBook a call
Book a call
Agent Skills/Backend / Infrastructure/Supabase & Repository Patterns
Backend / Infrastructureintermediate8 min

database Supabase & Repository Patterns

Implement database access and schema management with Supabase. Use when writing repository functions, database queries, creating tables, running migrations, RLS policies, or generating types. Enforces repository pattern, select *, schema-first workflow, and org isolation via RLS.

Install this skill

git submodule add https://github.com/modh-labs/playbook.git .playbook

One submodule installs all 17 skills. Reference .playbook/skills/supabase-patterns/SKILL.md from your AGENTS.md.


Supabase Patterns Skill

When This Skill Activates

This skill automatically activates when you:

  • Write or modify repository files (*.repository.ts)
  • Create database queries using the Supabase client
  • Discuss database schema changes, migrations, or RLS
  • Need CRUD operations for an entity
  • Work with generated TypeScript types from the database

Section 1: Repository Pattern

Core Principle

All database access goes through repository functions. Pages and server actions never call supabase.from() directly.

Server Action / Page
    | calls
Repository Function (typed, tested)
    | queries
Supabase Client (with RLS)
    | returns
Typed Data

Rule 1: Always use select *

Never pick specific columns. Always select * for the main table and all relations.

// Wrong -- column picking
.select("id, title, created_at")
.select(`id, title, user:users(id, name, email)`)

// Correct -- always select *
.select("*")
.select(`
  *,
  user:users!items_user_id_fkey(*)
`)

Why: Types automatically stay in sync with the schema. No maintenance burden when columns change.

Rule 2: Accept SupabaseClient as the first parameter

Every repository function receives the client -- it never creates one internally.

// Wrong -- creating client inside function
export async function getItems() {
  const supabase = await createClient();
  // ...
}

// Correct -- accept client as parameter
export async function getItems(
  supabase: Awaited<ReturnType<typeof createClient>>,
  filters?: ItemFilters
) {
  // ...
}

Why: Allows both authenticated clients (RLS-scoped) and service role clients (webhooks). Enables testing with mocks.

Rule 3: Use generated types from the database schema

Never create custom interfaces for database entities.

// Wrong -- custom interface
interface Item {
  id: string;
  title: string;
  created_at: string;
}

// Correct -- generated types
import type { Database } from "@/lib/supabase/database.types";
type Item = Database["public"]["Tables"]["items"]["Row"];
type ItemInsert = Database["public"]["Tables"]["items"]["Insert"];
type ItemUpdate = Database["public"]["Tables"]["items"]["Update"];

Rule 4: Let RLS handle organization_id

Never pass organization_id in queries. RLS policies read it from the JWT automatically.

// Wrong -- manual org filter
.eq("organization_id", orgId)

// Correct -- RLS handles it
const { data } = await supabase.from("items").select("*");

Repository File Template

import type { QueryData } from "@supabase/supabase-js";
import type { Database } from "@/lib/supabase/database.types";
import { createClient } from "@/lib/supabase/server";

type Item = Database["public"]["Tables"]["items"]["Row"];
type ItemInsert = Database["public"]["Tables"]["items"]["Insert"];
type ItemUpdate = Database["public"]["Tables"]["items"]["Update"];

// Query builder with standard relations
function buildItemQuery(
  supabase: Awaited<ReturnType<typeof createClient>>
) {
  return supabase.from("items").select(`
    *,
    category:categories!items_category_id_fkey(*)
  `);
}

export type ItemWithRelations = QueryData<
  ReturnType<typeof buildItemQuery>
>[number];

export async function getItems(
  supabase: Awaited<ReturnType<typeof createClient>>,
  filters?: { status?: string }
): Promise<ItemWithRelations[]> {
  let query = buildItemQuery(supabase)
    .order("created_at", { ascending: false });

  if (filters?.status) {
    query = query.eq("status", filters.status);
  }

  const { data, error } = await query;
  if (error) throw error;
  return data || [];
}

export async function getItemById(
  supabase: Awaited<ReturnType<typeof createClient>>,
  id: string
): Promise<ItemWithRelations | null> {
  const { data, error } = await buildItemQuery(supabase)
    .eq("id", id)
    .single();
  if (error) throw error;
  return data;
}

export async function createItem(
  supabase: Awaited<ReturnType<typeof createClient>>,
  input: ItemInsert
): Promise<Item> {
  const { data, error } = await supabase
    .from("items")
    .insert(input)
    .select("*")
    .single();
  if (error) throw error;
  return data;
}

export async function updateItem(
  supabase: Awaited<ReturnType<typeof createClient>>,
  id: string,
  updates: ItemUpdate
): Promise<Item> {
  const { data, error } = await supabase
    .from("items")
    .update(updates)
    .eq("id", id)
    .select("*")
    .single();
  if (error) throw error;
  return data;
}

export async function deleteItem(
  supabase: Awaited<ReturnType<typeof createClient>>,
  id: string
): Promise<void> {
  const { error } = await supabase
    .from("items")
    .delete()
    .eq("id", id);
  if (error) throw error;
}

Section 2: Schema-First Workflow

Core Workflow

Never manually write migration files. Always follow this sequence:

1. Edit domain SQL  ->  2. Run db diff  ->  3. Review migration  ->  4. Apply  ->  5. Generate types

Step 1: Update Domain SQL

Schema definitions live in domain-specific SQL files (e.g., schemas/items.sql, schemas/users.sql). Edit the appropriate domain file:

-- schemas/items.sql

CREATE TABLE items (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id text NOT NULL,
  title text NOT NULL,
  description text,
  status text DEFAULT 'active',
  created_at timestamptz DEFAULT now(),
  updated_at timestamptz DEFAULT now()
);

-- Indexes
CREATE INDEX idx_items_org ON items(organization_id);
CREATE INDEX idx_items_status ON items(status);

-- RLS
ALTER TABLE items ENABLE ROW LEVEL SECURITY;

CREATE POLICY "org_isolation" ON items
  FOR ALL USING (organization_id = (auth.jwt() ->> 'org_id')::text);

Step 2: Generate Migration from Diff

supabase db diff -f <migration_name> --linked

This creates a timestamped migration file automatically.

Step 3: Review the Generated Migration

Always review before applying. Check for correct table definitions, RLS policies, and no unintended changes.

Step 4: Apply Migration

supabase db push --linked

Step 5: Generate TypeScript Types

supabase gen types typescript --linked > lib/supabase/database.types.ts

Safe Migration Patterns

Adding columns:

-- Safe: nullable column (no downtime)
ALTER TABLE items ADD COLUMN notes text;

-- Safe: column with default
ALTER TABLE items ADD COLUMN is_active boolean DEFAULT true;

-- Requires backfill: NOT NULL without default
-- Step 1: Add nullable
ALTER TABLE items ADD COLUMN timezone text;
-- Step 2: Backfill
UPDATE items SET timezone = 'UTC' WHERE timezone IS NULL;
-- Step 3: Add constraint
ALTER TABLE items ALTER COLUMN timezone SET NOT NULL;

Renaming columns (multi-step):

-- Dangerous: direct rename breaks production
ALTER TABLE items RENAME COLUMN name TO title;  -- Don't do this

-- Safe: multi-step
-- Migration 1: Add new column
ALTER TABLE items ADD COLUMN title text;
-- Migration 2: Backfill + update code to use both
UPDATE items SET title = name WHERE title IS NULL;
-- Migration 3: After code deployed, drop old column
ALTER TABLE items DROP COLUMN name;

Full migration workflow: references/migration-workflow.md


Section 3: RLS Policies

Every Table Needs organization_id + RLS

No exceptions. Multi-tenancy requires org isolation at the database level.

Standard Pattern: Org Isolation

ALTER TABLE "public"."items" ENABLE ROW LEVEL SECURITY;

-- Authenticated users: own org data only
CREATE POLICY "items_org_read" ON "public"."items"
  FOR SELECT
  TO authenticated
  USING (organization_id = (auth.jwt() ->> 'org_id')::text);

-- Service role: full access (webhooks, system operations)
CREATE POLICY "items_service_role" ON "public"."items"
  FOR ALL
  TO service_role
  USING (true)
  WITH CHECK (true);

-- Grants
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE "public"."items" TO "authenticated";
GRANT ALL ON TABLE "public"."items" TO "service_role";

Security Function Pattern (recommended for new tables)

If your project defines a reusable security function (e.g., can_access_organization_data()), prefer it for new tables. It handles multiple JWT formats and service role bypass centrally:

CREATE POLICY "items_authenticated_read" ON "public"."items"
  FOR SELECT
  TO authenticated
  USING (can_access_organization_data("organization_id"));

Admin-Only Operations

CREATE POLICY "admin_only_delete" ON "public"."sensitive_items"
  FOR DELETE USING (
    organization_id = (auth.jwt() ->> 'org_id')::text
    AND (auth.jwt() ->> 'org_role') = 'org:admin'
  );

Public Read, Org Write

CREATE POLICY "public_read" ON "public"."public_content"
  FOR SELECT USING (true);

CREATE POLICY "org_write" ON "public"."public_content"
  FOR INSERT USING (organization_id = (auth.jwt() ->> 'org_id')::text);

Section 4: Type Generation

Generated Types Are the Source of Truth

Never define custom TypeScript interfaces for database entities. Use the generated types:

import type { Database } from "@/lib/supabase/database.types";

// Row types (for reads)
type Item = Database["public"]["Tables"]["items"]["Row"];

// Insert types (for creates -- optional fields have defaults)
type ItemInsert = Database["public"]["Tables"]["items"]["Insert"];

// Update types (for updates -- all fields optional)
type ItemUpdate = Database["public"]["Tables"]["items"]["Update"];

Inferred Types from Query Builders

For queries with joins, infer the type from the query builder:

import type { QueryData } from "@supabase/supabase-js";

function buildItemQuery(supabase: SupabaseClient) {
  return supabase.from("items").select(`
    *,
    category:categories!items_category_id_fkey(*)
  `);
}

// Inferred type includes the joined relation
export type ItemWithCategory = QueryData<
  ReturnType<typeof buildItemQuery>
>[number];

When to Regenerate Types

  • After applying any migration
  • After adding/removing columns
  • After changing column types or constraints
  • CI should auto-generate types on every PR

Section 5: Anti-Patterns

Anti-PatternWhy It Is WrongCorrect Alternative
supabase.from() in pages/actionsBypasses repository, no reuseUse repository functions
select("id, title, ...")Types break when schema changesselect("*")
createClient() inside repositoryCannot swap client for tests/webhooksAccept client as parameter
Custom interface Item {}Drifts from schema, manual maintenanceUse generated Database types
.eq("organization_id", orgId)Duplicates RLS, error-proneLet RLS handle org isolation
Manually writing migration SQLDrift between schema and migrationsUse db diff from domain SQL
Editing generated migration filesChanges lost on next diffEdit domain SQL, re-diff
Missing RLS on new tablesData leaks across organizationsAlways enable RLS + policy
Missing organization_id columnCannot enforce multi-tenancyRequired on every table
Forgetting type generation after migrationTypeScript types out of syncRun type generation after apply

Quick Reference

PatternCorrectWrong
Selectselect("*")select("id, name")
ClientFunction parametercreateClient() inside repo
TypesDatabase["public"]["Tables"]["x"]["Row"]interface X {}
Org filterLet RLS handle it.eq("organization_id", x)
Migration sourceDomain SQL filesManual migration SQL
Generate migrationdb diff commandWrite by hand
After migrationGenerate typesSkip type generation

Related Skills

lock

Security & Compliance

Enforce security standards including RLS policies, webhook signature verification, Zod validation, environment variable safety, GDPR consent, and SOC 2 compliance. Use when adding tables, webhook handlers, server actions, authentication, or preparing for security audits. Prevents XSS, injection, and data leakage.

Backend / Infrastructure
search-code

Code Quality Audit

Systematic audit and remediation of a route or module to production-grade quality. Use when auditing for SOLID violations, missing error handling, type safety gaps, dead code, duplicated patterns, or insufficient test coverage. Also use when remediating code to gold-standard clean architecture.

Backend / Infrastructure
heart-pulse

Cron Monitor Check-Ins

A cron monitor is only as real as the check-ins that feed it. Vercel's Sentry integration auto-registers a monitor per cron that EXPECTS check-ins; if the route never calls captureCheckIn, the monitor reports "missed check-in" on every tick forever — a permanent phantom outage that trains your team to ignore alerts. Use when adding/auditing any scheduled job (Vercel cron, GitHub Action schedule, k8s CronJob) wired to a heartbeat monitor (Sentry Crons, Cronitor, Healthchecks.io, Better Stack).

Backend / Infrastructure
←Back to Agent Skills