0%
Vibe44 Logo

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

TablePurposeKey Columns
profilesUser identity + subscription staterole, subscription_tier, ai_credits_balance, stripe_customer_id, onboarding_completed
activity_logsAudit trail (36 typed actions)user_id, action, entity_type, entity_id, metadata
notification_preferencesEmail opt-in/outuser_id, marketing_emails, security_emails

Billing Domain

TablePurposeKey Columns
subscriptionsActive subscriptionsuser_id, provider, provider_subscription_id, status, tier, current_period_end, cancel_at_period_end
purchasesLifetime + digital productsuser_id, product_id, provider, provider_payment_id, amount_in_cents
invoicesPayment receiptsuser_id, amount_in_cents, status, invoice_url, line_items (JSONB)
webhook_logsEvent audit + idempotencyevent_id (unique/provider), provider, status, error_message, attempts

Content Domain

TablePurposeKey Columns
postsBlog articlesslug, title, content, published_at, category_id, author_id
blog_categoriesBlog taxonomyname, slug
contact_submissionsContact form entriesname, email, message, status
waitlist_subscribersPre-launch email captureemail, full_name, locale (for i18n emails), referral_source
newsletter_subscribersNewsletter subscription domainemail, source, subscribed_at, unsubscribed_at

AI & Support Domain

TablePurposeKey Columns
ai_conversationsChat sessionsuser_id (nullable/guests), title, language
ai_messagesIndividual messagesconversation_id, role, content, token_count
knowledge_baseRAG document storetitle, content, embedding (vector 3072), category, source_url
support_ticketsHelp desk ticketsuser_id, subject, status, priority, assigned_to
ticket_repliesTicket threadsticket_id, user_id, content, is_admin_reply
generated_legal_docsAI-generated legal docstype, locale, version, content, is_published

Organization Domain

TablePurposeKey Columns
organizationsTeam/company entitiesname, slug, owner_id, logo_url
org_membersUsers ↔ orgs (many:many)org_id, user_id, role (owner/super-admin/member)
org_invitationsPending invitesorg_id, email, role, invited_by, expires_at

Developer Tools Domain

TablePurposeKey Columns
api_keysAPI key managementuser_id, key_hash (SHA-256), name, scopes, last_used_at
api_usage_logsAPI request trackingapi_key_id, endpoint, method, status_code, response_time_ms
projectsUser/org projectsuser_id, org_id, name, description, status
profiles(Subscription)subscription_tier drives project limits (managed via config/pricing.ts and tier_credits table)

Security Domain

TablePurposeKey Columns
passkeysWebAuthn credentialsuser_id, credential_id, public_key, counter, device_type
webauthn_challengesPersisted challenges for audit (60s expiry)challenge, user_id, expires_at, status
user_legal_consentsGDPR consent trackinguser_id, document_type, version, consented_at
auth_eventsRetained security event loguser_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

TablePurposeKey Columns
affiliatesAffiliate accountsuser_id, code, commission_rate, status, payout_method, payout_email
referralsTracked visitsaffiliate_id, referred_user_id, hashed ip_address / user_agent, status, commission_amount
affiliate_payoutsCommission paymentsaffiliate_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_logs

Usage

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

ConventionRationale
UUID primary keysGlobally unique, no sequential enumeration
timestamptzTimezone-aware, no UTC bugs
text not varcharPostgreSQL treats identically, avoids limits
Hard deletes + CASCADESimpler model;activity_logs preserves history
JSONB for metadataFlexible 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

  1. Edit lib/db/schema/ domain files
  2. npm run db:generate → versioned SQL in drizzle/
  3. Review generated SQL
  4. Apply via Supabase Dashboard → SQL Editor or CI

Copyright © 2026 Vibe44. Built with ❤️ by Scale with Alap.