Date: October 18, 2025
Goal: Migrate from session-based storage to persistent PostgreSQL database
Estimated Time: 8 hours
Audit Impact: Database 0/10 β 7/10, Security 6.9/10 β 8/10
- β Persistent data storage (survives server restarts)
- β Multi-user support (currently limited to single session)
- β Revenue protection (no localStorage hacks)
- β Audit trail (track all user interactions)
- β Analytics enhancement (historical data for PostHog)
- PostgreSQL database (Supabase or self-hosted)
- Prisma ORM for type-safe queries
- Migration scripts for schema changes
- Connection pooling for performance
- Database health check endpoint
Pros:
- β Free tier: 500MB database, 2GB bandwidth/month
- β Auto-generated REST API & TypeScript types
- β Built-in auth (can replace custom auth later)
- β Real-time subscriptions
- β Row-level security (RLS)
- β Automatic backups
- β Dashboard for data management
- β Edge functions (serverless)
- β No DevOps overhead
Cons:
β οΈ Vendor lock-in (mitigated by standard PostgreSQL)β οΈ Cold starts on free tierβ οΈ External dependency
Cost: $0/month (free tier) β $25/month (pro) when scaling
Pros:
- β Full control over infrastructure
- β No vendor lock-in
- β Prisma provides excellent TypeScript DX
Cons:
β οΈ Requires hosting (Railway, Render, or local Docker)β οΈ Manual backup setupβ οΈ DevOps overhead (monitoring, scaling, security)β οΈ Additional cost ($5-10/month minimum)
Cost: $5-10/month (Railway/Render)
Reasoning:
- Free tier sufficient for alpha/MVP
- Fastest setup (< 1 hour)
- Production-ready out of the box
- Can migrate to self-hosted later if needed
- Standard PostgreSQL (no lock-in)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
stripe_customer_id TEXT UNIQUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_users_stripe_customer ON users(stripe_customer_id);
CREATE INDEX idx_users_email ON users(email);Purpose: Central user identity, links to Stripe
CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
tier TEXT NOT NULL CHECK (tier IN ('free', 'basic', 'pro', 'enterprise')),
status TEXT NOT NULL CHECK (status IN ('active', 'canceled', 'past_due', 'trialing')),
stripe_subscription_id TEXT UNIQUE,
current_period_start TIMESTAMPTZ NOT NULL,
current_period_end TIMESTAMPTZ NOT NULL,
cancel_at_period_end BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_subscriptions_user_id ON subscriptions(user_id);
CREATE INDEX idx_subscriptions_stripe_id ON subscriptions(stripe_subscription_id);
CREATE INDEX idx_subscriptions_status ON subscriptions(status);Purpose: Track subscription state, billing periods
CREATE TABLE usage (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
period_start TIMESTAMPTZ NOT NULL,
period_end TIMESTAMPTZ NOT NULL,
interactions_count INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, period_start)
);
CREATE INDEX idx_usage_user_id ON usage(user_id);
CREATE INDEX idx_usage_period ON usage(period_start, period_end);Purpose: Track monthly interaction counts per user
CREATE TABLE messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
deck_type TEXT NOT NULL CHECK (deck_type IN ('alpha', 'defense', 'medical')),
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
content TEXT NOT NULL,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_messages_user_id ON messages(user_id);
CREATE INDEX idx_messages_deck_type ON messages(deck_type);
CREATE INDEX idx_messages_created_at ON messages(created_at DESC);Purpose: Chat history, analytics, audit trail
CREATE TABLE decks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
deck_type TEXT NOT NULL CHECK (deck_type IN ('alpha', 'defense', 'medical')),
state JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, deck_type)
);
CREATE INDEX idx_decks_user_id ON decks(user_id);
CREATE INDEX idx_decks_type ON decks(deck_type);Purpose: Persist deck state (case details, tasks, etc.)
-
Create Supabase Project:
# Go to https://supabase.com # Sign up / login # Create new project: "darkdeck-alpha" # Region: Choose closest to users (US East recommended) # Database password: Generate strong password
-
Get Connection Details:
# Project Settings β Database # Copy: # - Connection string (postgres://...) # - Supabase URL (https://xxx.supabase.co) # - Anon public key # - Service role key (keep secret!)
-
Add Environment Variables:
# .env.local DATABASE_URL="postgresql://postgres:[password]@db.[project-ref].supabase.co:5432/postgres" NEXT_PUBLIC_SUPABASE_URL="https://[project-ref].supabase.co" NEXT_PUBLIC_SUPABASE_ANON_KEY="eyJ..." SUPABASE_SERVICE_ROLE_KEY="eyJ..." # Server-side only
pnpm add @supabase/supabase-js
pnpm add -D prisma
pnpm add @prisma/client-
Initialize Prisma:
npx prisma init
-
Create Schema (
prisma/schema.prisma):generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(uuid()) email String @unique stripeCustomerId String? @unique @map("stripe_customer_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") subscriptions Subscription[] usage Usage[] messages Message[] decks Deck[] @@map("users") } model Subscription { id String @id @default(uuid()) userId String @map("user_id") tier String status String stripeSubscriptionId String? @unique @map("stripe_subscription_id") currentPeriodStart DateTime @map("current_period_start") currentPeriodEnd DateTime @map("current_period_end") cancelAtPeriodEnd Boolean @default(false) @map("cancel_at_period_end") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) @@index([stripeSubscriptionId]) @@index([status]) @@map("subscriptions") } model Usage { id String @id @default(uuid()) userId String @map("user_id") periodStart DateTime @map("period_start") periodEnd DateTime @map("period_end") interactionsCount Int @default(0) @map("interactions_count") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, periodStart]) @@index([userId]) @@index([periodStart, periodEnd]) @@map("usage") } model Message { id String @id @default(uuid()) userId String @map("user_id") deckType String @map("deck_type") role String content String metadata Json @default("{}") createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) @@index([deckType]) @@index([createdAt(sort: Desc)]) @@map("messages") } model Deck { id String @id @default(uuid()) userId String @map("user_id") deckType String @map("deck_type") state Json @default("{}") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, deckType]) @@index([userId]) @@index([deckType]) @@map("decks") }
-
Generate Migration:
npx prisma migrate dev --name initial_schema
-
Generate Prisma Client:
npx prisma generate
lib/db.ts:
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;lib/supabase-server.ts (for server-side):
import { createClient } from '@supabase/supabase-js';
export const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
auth: {
autoRefreshToken: false,
persistSession: false,
},
}
);lib/supabase-client.ts (for client-side):
import { createClient } from '@supabase/supabase-js';
export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);Refactor lib/subscription.ts to use database:
import { prisma } from './db';
import type { User, Subscription as PrismaSubscription } from '@prisma/client';
export async function getSubscriptionFromDB(userId: string): Promise<SubscriptionData> {
const subscription = await prisma.subscription.findFirst({
where: {
userId,
status: 'active',
},
orderBy: {
currentPeriodEnd: 'desc',
},
});
if (!subscription) {
return {
tier: 'free',
status: 'active',
periodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
};
}
return {
tier: subscription.tier as 'free' | 'basic' | 'pro' | 'enterprise',
status: subscription.status as 'active' | 'canceled' | 'past_due',
periodEnd: subscription.currentPeriodEnd,
stripeSubscriptionId: subscription.stripeSubscriptionId || undefined,
};
}
export async function getUsageFromDB(userId: string): Promise<UsageData> {
const now = new Date();
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
let usage = await prisma.usage.findUnique({
where: {
userId_periodStart: {
userId,
periodStart,
},
},
});
if (!usage) {
usage = await prisma.usage.create({
data: {
userId,
periodStart,
periodEnd,
interactionsCount: 0,
},
});
}
return {
count: usage.interactionsCount,
periodStart,
periodEnd,
};
}
export async function incrementUsageDB(userId: string): Promise<void> {
const now = new Date();
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
await prisma.usage.upsert({
where: {
userId_periodStart: {
userId,
periodStart,
},
},
update: {
interactionsCount: {
increment: 1,
},
},
create: {
userId,
periodStart,
periodEnd,
interactionsCount: 1,
},
});
}Modify app/api/stripe/webhook/route.ts to write to database:
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription;
const customerId = subscription.customer as string;
// Find or create user
let user = await prisma.user.findUnique({
where: { stripeCustomerId: customerId },
});
if (!user) {
// Create user from Stripe customer data
const customer = await stripe.customers.retrieve(customerId);
user = await prisma.user.create({
data: {
email: (customer as Stripe.Customer).email || `customer_${customerId}@stripe.com`,
stripeCustomerId: customerId,
},
});
}
// Upsert subscription
await prisma.subscription.upsert({
where: { stripeSubscriptionId: subscription.id },
update: {
tier: getTierFromPriceId(subscription.items.data[0].price.id),
status: subscription.status,
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
create: {
userId: user.id,
stripeSubscriptionId: subscription.id,
tier: getTierFromPriceId(subscription.items.data[0].price.id),
status: subscription.status,
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
break;
}Modify app/api/auth/login/route.ts to create/retrieve user:
// After successful password validation
const user = await prisma.user.upsert({
where: { email: 'admin@darkdeck.local' }, // Or derive from auth system
update: {},
create: {
email: 'admin@darkdeck.local',
},
});
// Store user ID in session token payload
const payloadObj = {
v: 1,
iat: now,
exp,
userId: user.id, // Add user ID to JWT
};Add to app/terminal/TerminalClientPage.tsx:
// After AI response received
await fetch('/api/messages/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
deckType: activeMode,
role: 'user',
content: userMessage,
}),
});
await fetch('/api/messages/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
deckType: activeMode,
role: 'assistant',
content: fullText,
}),
});Create app/api/messages/save/route.ts:
export async function POST(req: Request) {
const session = await getSession(req);
if (!session?.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { deckType, role, content, metadata } = await req.json();
await prisma.message.create({
data: {
userId: session.userId,
deckType,
role,
content,
metadata: metadata || {},
},
});
return NextResponse.json({ ok: true });
}Create app/api/health/route.ts:
import { prisma } from '@/lib/db';
export async function GET() {
try {
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json({
status: 'healthy',
database: 'connected',
timestamp: new Date().toISOString(),
});
} catch (error) {
return NextResponse.json(
{
status: 'unhealthy',
database: 'disconnected',
error: (error as Error).message,
},
{ status: 503 }
);
}
}# Test database connection
curl http://localhost:3000/api/health
# Test Prisma Studio
npx prisma studio
# Run migrations
npx prisma migrate deploy
# Generate types
npx prisma generate
# Seed test data
npx prisma db seed- β New users β Database
- β Existing sessions β Keep using session storage
- β No data loss
- β Prompt users to re-login
- β Copy session data to database
- β Monitor for issues
- β Disable session storage
- β All operations use database
- β Remove fallback code
| Metric | Target | Validation |
|---|---|---|
| Database uptime | 99.9% | Health check endpoint |
| Query response time | <100ms | Prisma logging |
| Connection pool size | 10-20 | Monitor Supabase dashboard |
| Data consistency | 100% | No orphaned records |
| Zero data loss | β | All writes confirmed |
-- Enable RLS on all tables
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE usage ENABLE ROW LEVEL SECURITY;
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE decks ENABLE ROW LEVEL SECURITY;
-- Users can only read their own data
CREATE POLICY "Users can view own data" ON users
FOR SELECT USING (auth.uid() = id);
CREATE POLICY "Users can view own subscriptions" ON subscriptions
FOR SELECT USING (auth.uid() = user_id);
-- Admin service role can do everything (for Next.js API routes)
CREATE POLICY "Service role full access" ON users
FOR ALL USING (auth.jwt() ->> 'role' = 'service_role');# Never commit these
DATABASE_URL="..." # Contains password
SUPABASE_SERVICE_ROLE_KEY="..." # Full admin access
# Safe to commit (public)
NEXT_PUBLIC_SUPABASE_URL="https://..."
NEXT_PUBLIC_SUPABASE_ANON_KEY="eyJ..." # RLS-protected- Supabase project created and configured
- Prisma schema matches requirements
- All migrations run successfully
- Users table populated from auth
- Subscriptions sync from Stripe webhooks
- Usage tracking persists to database
- Messages stored after each interaction
- Deck state persists across sessions
- Health check endpoint returns 200
- Zero errors in production logs
- All tests pass
- Documentation updated
- README.md: Add database setup instructions
- DAY_4_COMPLETE.md: Document migration process
- .env.example: Add Supabase variables
- DEPLOYMENT.md: Add database deployment steps
If issues arise:
- Disable database writes (feature flag)
- Revert to session storage (keep fallback code)
- Analyze error logs in Sentry
- Fix issues in dev environment
- Re-deploy after testing
Next Actions:
- Create Supabase project (30 min)
- Install dependencies (10 min)
- Set up Prisma (30 min)
- Implement database client (30 min)
- Update subscription system (2 hours)
- Update Stripe webhook (1 hour)
- Update authentication (1 hour)
- Persist messages (1 hour)
- Add health check (30 min)
- Test & validate (1 hour)
Total Estimated Time: 8 hours
Break into: 2 sessions of 4 hours each