Database
PostgreSQL 15 via Supabase. 32 SQL tables. Drizzle ORM for app-managed models. RLS on every table. pgvector for RAG.
Schema Overview
Core Domain
| Table | Purpose | Key Columns |
|---|---|---|
profiles | User identity + subscription state | role, subscription_tier, ai_credits_balance, stripe_customer_id, onboarding_completed |
activity_logs | Audit trail (36 typed actions) | user_id, action, entity_type, entity_id, metadata |
notification_preferences | Email opt-in/out | user_id, marketing_emails, security_emails |
Billing Domain
| Table | Purpose | Key Columns |
|---|---|---|
subscriptions | Active subscriptions | user_id, provider, provider_subscription_id, status, tier, current_period_end, cancel_at_period_end |
purchases | Lifetime + digital products | user_id, product_id, provider, provider_payment_id, amount_in_cents |
invoices | Payment receipts | user_id, amount_in_cents, status, invoice_url, line_items (JSONB) |
webhook_logs | Event audit + idempotency | event_id (unique/provider), provider, status, error_message, attempts |
Content Domain
| Table | Purpose | Key Columns |
|---|---|---|
posts | Blog articles | slug, title, content, published_at, category_id, author_id |
blog_categories | Blog taxonomy | name, slug |
contact_submissions | Contact form entries | name, email, message, status |
waitlist_subscribers | Pre-launch email capture | email, full_name, locale (for i18n emails), referral_source |
newsletter_subscribers | Newsletter subscription domain | email, source, subscribed_at, unsubscribed_at |
AI & Support Domain
| Table | Purpose | Key Columns |
|---|---|---|
ai_conversations | Chat sessions | user_id (nullable/guests), title, language |
ai_messages | Individual messages | conversation_id, role, content, token_count |
knowledge_base | RAG document store | title, content, embedding (vector 3072), category, source_url |
support_tickets | Help desk tickets | user_id, subject, status, priority, assigned_to |
ticket_replies | Ticket threads | ticket_id, user_id, content, is_admin_reply |
generated_legal_docs | AI-generated legal docs | type, locale, version, content, is_published |
Organization Domain
| Table | Purpose | Key Columns |
|---|---|---|
organizations | Team/company entities | name, slug, owner_id, logo_url |
org_members | Users ↔ orgs (many:many) | org_id, user_id, role (owner/super-admin/member) |
org_invitations | Pending invites | org_id, email, role, invited_by, expires_at |
Developer Tools Domain
| Table | Purpose | Key Columns |
|---|---|---|
api_keys | API key management | user_id, key_hash (SHA-256), name, scopes, last_used_at |
api_usage_logs | API request tracking | api_key_id, endpoint, method, status_code, response_time_ms |
projects | User/org projects | user_id, org_id, name, description, status |
profiles | (Subscription) | subscription_tier drives project limits (managed via config/pricing.ts and tier_credits table) |
Security Domain
| Table | Purpose | Key Columns |
|---|---|---|
passkeys | WebAuthn credentials | user_id, credential_id, public_key, counter, device_type |
webauthn_challenges | Persisted challenges for audit (60s expiry) | challenge, user_id, expires_at, status |
user_legal_consents | GDPR consent tracking | user_id, document_type, version, consented_at |
auth_events | Retained security event log | user_id, event_type, ip_address, severity, metadata |
auth_events is intentionally used for retained, privacy-minimized security
facts such as account_deleted. User-scoped activity_logs can cascade during
profile deletion; deletion traceability that must survive account removal belongs
in auth_events.
Affiliate Domain
| Table | Purpose | Key Columns |
|---|---|---|
affiliates | Affiliate accounts | user_id, code, commission_rate, status, payout_method, payout_email |
referrals | Tracked visits | affiliate_id, referred_user_id, hashed ip_address / user_agent, status, commission_amount |
affiliate_payouts | Commission payments | affiliate_id, amount, status; completion is pending-only |
Drizzle ORM
Schema Organization
lib/db/
├── index.ts # Drizzle client
└── schema/
├── index.ts # Barrel re-export
├── activity.ts # activity_logs
├── affiliates.ts # affiliates, referrals, affiliate_payouts
├── auth.ts # auth_events
├── billing.ts # subscriptions, purchases, invoices
├── blog.ts # posts, blog_categories
├── legal.ts # generated_legal_docs, user_legal_consents
├── marketing.ts # contact_submissions, waitlist, newsletter_subscribers
├── notifications.ts # notification_preferences
├── organizations.ts # organizations, org_members, org_invitations
├── projects.ts # projects
├── security.ts # passkeys, webauthn_challenges
├── support.ts # ai_conversations, ai_messages, knowledge_base, tickets, replies
├── users.ts # profiles
└── webhooks.ts # webhook_logsUsage
import { db } from "@/lib/db";
import { profiles, subscriptions } from "@/lib/db/schema";
import { eq, and, desc } from "drizzle-orm";
// Select
const user = await db
.select({
id: profiles.id,
email: profiles.email,
role: profiles.role,
})
.from(profiles)
.where(eq(profiles.id, userId))
.limit(1);
// Join
const userWithSub = await db
.select({
userId: profiles.id,
email: profiles.email,
tier: profiles.subscriptionTier,
subscriptionStatus: subscriptions.status,
})
.from(profiles)
.leftJoin(subscriptions, eq(profiles.id, subscriptions.userId))
.where(eq(profiles.id, userId));
// Insert
const [newProject] = await db
.insert(projects)
.values({ name: "My Project", userId: user.id })
.returning();
// Update with ownership gate
await db
.update(projects)
.set({ name: "Updated" })
.where(and(eq(projects.id, projectId), eq(projects.userId, user.id)));
// Delete with ownership gate
await db
.delete(projects)
.where(and(eq(projects.id, projectId), eq(projects.userId, user.id)));Design Conventions
| Convention | Rationale |
|---|---|
| UUID primary keys | Globally unique, no sequential enumeration |
timestamptz | Timezone-aware, no UTC bugs |
text not varchar | PostgreSQL treats identically, avoids limits |
| Hard deletes + CASCADE | Simpler model;activity_logs preserves history |
| JSONB for metadata | Flexible extension (goals, line_items) |
Indexes inside pgTable() | Turbopack crashes with standalone (Drizzle #5064) |
SQL Functions
deduct_credits
Atomic credit deduction with FOR UPDATE row locking:
CREATE OR REPLACE FUNCTION public.deduct_credits(
p_user_id UUID,
p_amount INTEGER DEFAULT 1,
p_action TEXT DEFAULT 'api_call'
) RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_current_tier TEXT;
v_credits_used INTEGER;
v_credits_limit INTEGER;
v_remaining INTEGER;
BEGIN
IF p_amount <= 0 THEN
RETURN jsonb_build_object('success', false, 'error', 'INVALID_AMOUNT');
END IF;
SELECT COALESCE(subscription_tier, 'free'), COALESCE(credits_used, 0)
INTO v_current_tier, v_credits_used
FROM profiles WHERE id = p_user_id FOR UPDATE;
-- Dynamic tier-to-credits lookup from tier_credits table.
SELECT COALESCE(tc.credits_limit, 100)
INTO v_credits_limit
FROM (SELECT v_current_tier AS tid) AS q
LEFT JOIN public.tier_credits tc ON tc.tier_id = q.tid;
-- Handle unlimited credits sentinel (-1).
IF v_credits_limit = -1 THEN
RETURN jsonb_build_object('success', true, 'credits_limit', -1);
END IF;
v_remaining := v_credits_limit - v_credits_used;
IF v_remaining < p_amount THEN
RETURN jsonb_build_object('success', false, 'error', 'INSUFFICIENT_CREDITS');
END IF;
UPDATE profiles
SET credits_used = credits_used + p_amount
WHERE id = p_user_id;
INSERT INTO activity_logs (user_id, action, entity_type, entity_id, metadata)
VALUES (
p_user_id, 'credits_deducted', 'profile', p_user_id,
jsonb_build_object('cost', p_amount, 'tier_limit', v_credits_limit)
);
RETURN jsonb_build_object('success', true, 'credits_remaining', v_remaining - p_amount);
END;
$$;
### Serializable Application Logic
While SQL functions like `deduct_credits` handle atomicity for single records, multi-table flows (like **Affiliate Payouts**) use Drizzle's `db.transaction(..., { isolationLevel: 'serializable' })`. This guarantees that the read snapshot used for payout calculation remains consistent throughout the update and insertion phase, even under high concurrency from external webhooks.
-- Restrict to service_role only
REVOKE ALL ON FUNCTION public.deduct_credits(UUID, INTEGER) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.deduct_credits(UUID, INTEGER) FROM anon;
REVOKE ALL ON FUNCTION public.deduct_credits(UUID, INTEGER) FROM authenticated;
GRANT EXECUTE ON FUNCTION public.deduct_credits(UUID, INTEGER) TO service_role;search_knowledge_base
pgvector cosine similarity for RAG:
CREATE OR REPLACE FUNCTION search_knowledge_base(
query_embedding vector(3072),
match_threshold float DEFAULT 0.7,
match_count int DEFAULT 5
)
RETURNS TABLE (id UUID, title TEXT, content TEXT, similarity float) AS $$
BEGIN
RETURN QUERY
SELECT kb.id, kb.title, kb.content,
1 - (kb.embedding <=> query_embedding) AS similarity
FROM knowledge_base kb
WHERE 1 - (kb.embedding <=> query_embedding) > match_threshold
ORDER BY kb.embedding <=> query_embedding
LIMIT match_count;
END;
$$ LANGUAGE plpgsql;Indexing Strategy
Composite (Multi-Column)
CREATE INDEX idx_api_usage_logs_key_created ON api_usage_logs(api_key_id, created_at);
CREATE INDEX idx_referrals_affiliate_created ON referrals(affiliate_id, created_at);
CREATE INDEX idx_org_members_org_user ON org_members(org_id, user_id);Partial (Conditional)
CREATE INDEX idx_subscriptions_active ON subscriptions(user_id)
WHERE status IN ('active', 'trialing');
CREATE INDEX idx_posts_published ON posts(published_at)
WHERE published_at IS NOT NULL;
CREATE INDEX idx_org_invitations_pending ON org_invitations(email)
WHERE accepted_at IS NULL AND expires_at > NOW();
CREATE UNIQUE INDEX webhook_logs_provider_event_id_unique
ON webhook_logs(provider, event_id)
WHERE event_id IS NOT NULL;
CREATE UNIQUE INDEX subscriptions_provider_subscription_id_unique
ON subscriptions(provider, provider_subscription_id)
WHERE provider_subscription_id IS NOT NULL;
CREATE UNIQUE INDEX purchases_provider_payment_id_unique
ON purchases(provider, provider_payment_id)
WHERE provider_payment_id IS NOT NULL;The payment indexes are intentionally provider-scoped because provider object IDs are only guaranteed unique inside a provider. webhook_logs(provider, event_id) deduplicates delivery attempts; the subscription and purchase indexes deduplicate the final billing state after retries, out-of-order events, or concurrent webhook delivery.
generated_legal_docs supports only privacy_policy, terms_of_service,
cookie_policy, and refund_policy. The SQL schema and Drizzle schema both
enforce version-aware uniqueness on type + locale + version, so published
legal documents can be regenerated without violating the historical version
model.
RLS-Optimized
CREATE INDEX idx_profiles_role ON profiles(id, role);Vector
CREATE INDEX idx_knowledge_base_embedding ON knowledge_base
USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);️ RLS
All 32 tables. Policies in supabase/schema.sql.
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own projects"
ON projects FOR SELECT USING ((select auth.uid()) = user_id);
CREATE POLICY "Users can create projects"
ON projects FOR INSERT WITH CHECK ((select auth.uid()) = user_id);
CREATE POLICY "Users can update own projects"
ON projects FOR UPDATE USING ((select auth.uid()) = user_id);
CREATE POLICY "Admins can view all projects"
ON projects FOR SELECT USING (
EXISTS (SELECT 1 FROM profiles WHERE id = (select auth.uid()) AND role = 'super-admin')
);
(select auth.uid())subquery = cached once per query, not per row.
Migration Commands
npm run db:push # Push to Supabase (dev)
npm run db:generate # Generate SQL migrations (production)
npm run db:studio # Drizzle Studio (localhost:4983)Production Migrations
- Edit
lib/db/schema/domain files npm run db:generate→ versioned SQL indrizzle/- Review generated SQL
- Apply via Supabase Dashboard → SQL Editor or CI
Copyright © 2026 Vibe44. Built with ❤️ by Scale with Alap.