mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:01:09 +02:00
Better Auth generates non-UUID user IDs (32-char base62 format like
'otUe1YrfENPdHnrF3g1vSBfpkQfambCZ'). Changed all `uuid('user_id')` to
`text('user_id')` in Drizzle schemas for consistency with auth system.
Affected packages/apps:
- apps/calendar, clock, picture, zitare
- games/figgos, voxelava
- packages/manadeck-database, news-database, uload-database
- services/mana-core-auth (feedback schema)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
23 KiB
23 KiB
ManaDeck: Migration zu PostgreSQL + Drizzle ORM
Übersicht
Dieses Dokument beschreibt die Migration von ManaDeck von Supabase zu einer selbst-gehosteten PostgreSQL-Datenbank mit Drizzle ORM.
Aktuelle Architektur
┌─────────────────┐ ┌─────────────────┐
│ ManaDeck Web │ │ ManaDeck Mobile │
│ (SvelteKit) │ │ (Expo) │
└────────┬────────┘ └────────┬────────┘
│ │
└───────────┬───────────┘
│
┌───────────▼───────────┐
│ ManaDeck Backend │
│ (NestJS) │
└───────────┬───────────┘
│
┌────────────────┼────────────────┐
│ │ │
▼ ▼ ▼
┌────────┐ ┌──────────┐ ┌──────────┐
│Supabase│ │Mana Core │ │ OpenAI │
│ DB │ │ (Auth) │ │ API │
└────────┘ └──────────┘ └──────────┘
Ziel-Architektur
┌─────────────────┐ ┌─────────────────┐
│ ManaDeck Web │ │ ManaDeck Mobile │
│ (SvelteKit) │ │ (Expo) │
└────────┬────────┘ └────────┬────────┘
│ │
└───────────┬───────────┘
│
┌───────────▼───────────┐
│ ManaDeck Backend │
│ (NestJS + Drizzle) │
└───────────┬───────────┘
│
┌────────────────┼────────────────┐
│ │ │
▼ ▼ ▼
┌────────┐ ┌──────────┐ ┌──────────┐
│ PostgreSQL │ │Mana Core │ │ OpenAI │
│ (Self) │ │ (Auth) │ │ API │
└────────────┘ └──────────┘ └──────────┘
Datenbank-Schema
Tabellen
1. decks
CREATE TABLE decks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL, -- Mana Core User ID
title VARCHAR(255) NOT NULL,
description TEXT,
cover_image_url TEXT,
is_public BOOLEAN DEFAULT false,
is_featured BOOLEAN DEFAULT false,
featured_at TIMESTAMPTZ,
settings JSONB DEFAULT '{}',
tags TEXT[] DEFAULT '{}',
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_decks_user_id ON decks(user_id);
CREATE INDEX idx_decks_is_public ON decks(is_public);
CREATE INDEX idx_decks_is_featured ON decks(is_featured);
2. cards
CREATE TABLE cards (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
deck_id UUID NOT NULL REFERENCES decks(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
title VARCHAR(255),
content JSONB NOT NULL,
card_type VARCHAR(20) NOT NULL CHECK (card_type IN ('text', 'flashcard', 'quiz', 'mixed')),
ai_model VARCHAR(100),
ai_prompt TEXT,
version INTEGER DEFAULT 1,
is_favorite BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_cards_deck_id ON cards(deck_id);
CREATE INDEX idx_cards_position ON cards(deck_id, position);
3. study_sessions
CREATE TABLE study_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
deck_id UUID NOT NULL REFERENCES decks(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
mode VARCHAR(20) NOT NULL CHECK (mode IN ('all', 'new', 'review', 'favorites', 'random')),
total_cards INTEGER NOT NULL DEFAULT 0,
completed_cards INTEGER NOT NULL DEFAULT 0,
correct_cards INTEGER NOT NULL DEFAULT 0,
started_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ,
time_spent_seconds INTEGER DEFAULT 0
);
CREATE INDEX idx_study_sessions_user_id ON study_sessions(user_id);
CREATE INDEX idx_study_sessions_deck_id ON study_sessions(deck_id);
4. card_progress
CREATE TABLE card_progress (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
card_id UUID NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
ease_factor DECIMAL(4,2) DEFAULT 2.5,
interval INTEGER DEFAULT 0,
repetitions INTEGER DEFAULT 0,
last_reviewed TIMESTAMPTZ,
next_review TIMESTAMPTZ,
status VARCHAR(20) DEFAULT 'new' CHECK (status IN ('new', 'learning', 'review', 'relearning')),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, card_id)
);
CREATE INDEX idx_card_progress_user_id ON card_progress(user_id);
CREATE INDEX idx_card_progress_next_review ON card_progress(next_review);
5. deck_templates
CREATE TABLE deck_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
description TEXT,
category VARCHAR(100),
template_data JSONB NOT NULL,
is_active BOOLEAN DEFAULT true,
is_public BOOLEAN DEFAULT true,
popularity INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_deck_templates_category ON deck_templates(category);
CREATE INDEX idx_deck_templates_is_active ON deck_templates(is_active);
6. ai_generations
CREATE TABLE ai_generations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
deck_id UUID REFERENCES decks(id) ON DELETE SET NULL,
function_name VARCHAR(100) NOT NULL,
prompt TEXT NOT NULL,
model VARCHAR(100),
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'completed', 'failed')),
metadata JSONB DEFAULT '{}',
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_ai_generations_user_id ON ai_generations(user_id);
CREATE INDEX idx_ai_generations_status ON ai_generations(status);
7. user_stats (für Leaderboard)
CREATE TABLE user_stats (
user_id UUID PRIMARY KEY,
total_wins INTEGER DEFAULT 0,
total_sessions INTEGER DEFAULT 0,
total_cards_studied INTEGER DEFAULT 0,
total_time_seconds INTEGER DEFAULT 0,
average_accuracy DECIMAL(5,2) DEFAULT 0,
streak_days INTEGER DEFAULT 0,
last_study_date DATE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
Drizzle Schema
Dateistruktur
manadeck/
├── packages/
│ └── database/ # Neues shared database package
│ ├── src/
│ │ ├── schema/
│ │ │ ├── decks.ts
│ │ │ ├── cards.ts
│ │ │ ├── studySessions.ts
│ │ │ ├── cardProgress.ts
│ │ │ ├── deckTemplates.ts
│ │ │ ├── aiGenerations.ts
│ │ │ ├── userStats.ts
│ │ │ └── index.ts
│ │ ├── client.ts
│ │ ├── migrate.ts
│ │ └── index.ts
│ ├── drizzle/
│ │ └── migrations/
│ ├── drizzle.config.ts
│ ├── package.json
│ └── tsconfig.json
Schema-Definitionen (Drizzle)
schema/decks.ts
import { pgTable, uuid, varchar, text, boolean, timestamp, jsonb } from 'drizzle-orm/pg-core';
export const decks = pgTable('decks', {
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(), // text, not uuid - Better Auth uses non-UUID IDs
title: varchar('title', { length: 255 }).notNull(),
description: text('description'),
coverImageUrl: text('cover_image_url'),
isPublic: boolean('is_public').default(false),
isFeatured: boolean('is_featured').default(false),
featuredAt: timestamp('featured_at', { withTimezone: true }),
settings: jsonb('settings').default({}),
tags: text('tags').array().default([]),
metadata: jsonb('metadata').default({}),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
});
export type Deck = typeof decks.$inferSelect;
export type NewDeck = typeof decks.$inferInsert;
schema/cards.ts
import {
pgTable,
uuid,
varchar,
text,
integer,
boolean,
timestamp,
jsonb,
} from 'drizzle-orm/pg-core';
import { decks } from './decks';
export const cardTypeEnum = pgEnum('card_type', ['text', 'flashcard', 'quiz', 'mixed']);
export const cards = pgTable('cards', {
id: uuid('id').primaryKey().defaultRandom(),
deckId: uuid('deck_id')
.notNull()
.references(() => decks.id, { onDelete: 'cascade' }),
position: integer('position').notNull().default(0),
title: varchar('title', { length: 255 }),
content: jsonb('content').notNull(),
cardType: cardTypeEnum('card_type').notNull(),
aiModel: varchar('ai_model', { length: 100 }),
aiPrompt: text('ai_prompt'),
version: integer('version').default(1),
isFavorite: boolean('is_favorite').default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
});
export type Card = typeof cards.$inferSelect;
export type NewCard = typeof cards.$inferInsert;
schema/index.ts
export * from './decks';
export * from './cards';
export * from './studySessions';
export * from './cardProgress';
export * from './deckTemplates';
export * from './aiGenerations';
export * from './userStats';
// Relations
export { decksRelations } from './decks';
export { cardsRelations } from './cards';
Client Setup
client.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
const connectionString = process.env.DATABASE_URL!;
// For connection pooling in serverless environments
const client = postgres(connectionString, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
export const db = drizzle(client, { schema });
export type Database = typeof db;
Migrationsschritte
Phase 1: Setup (Tag 1-2)
1.1 PostgreSQL Server aufsetzen
# Option A: Railway.app (empfohlen für Staging)
# Erstelle neues Projekt auf railway.app
# Füge PostgreSQL Plugin hinzu
# Option B: Docker lokal
docker run -d \
--name manadeck-postgres \
-e POSTGRES_USER=manadeck \
-e POSTGRES_PASSWORD=secure_password \
-e POSTGRES_DB=manadeck \
-p 5432:5432 \
postgres:16-alpine
# Option C: Neon.tech (Serverless)
# Erstelle neues Projekt auf neon.tech
1.2 Database Package erstellen
cd /Users/tillschneider/Documents/__00__Code/manacore-monorepo
mkdir -p packages/manadeck-database
cd packages/manadeck-database
pnpm init
1.3 Dependencies installieren
pnpm add drizzle-orm postgres
pnpm add -D drizzle-kit typescript @types/node
Phase 2: Schema & Migration (Tag 2-3)
2.1 Drizzle Schema erstellen
- Alle Tabellen wie oben definiert
- Relations definieren
- Indexes definieren
2.2 Initial Migration generieren
pnpm drizzle-kit generate
pnpm drizzle-kit migrate
2.3 Daten von Supabase exportieren
# In Supabase Dashboard: SQL Editor
# Export alle Tabellen als CSV oder pg_dump
# Oder via CLI:
pg_dump -h db.your-project.supabase.co \
-U postgres \
-d postgres \
--data-only \
--table=decks \
--table=cards \
--table=study_sessions \
--table=card_progress \
--table=deck_templates \
--table=ai_generations \
--table=user_stats \
> manadeck_data.sql
Phase 3: Backend Migration (Tag 3-5)
3.1 Repository Pattern implementieren
// repositories/deck.repository.ts
import { db } from '@manadeck/database';
import { decks, cards } from '@manadeck/database/schema';
import { eq, and, or, desc } from 'drizzle-orm';
export class DeckRepository {
async findAllByUser(userId: string) {
return db.query.decks.findMany({
where: eq(decks.userId, userId),
orderBy: desc(decks.updatedAt),
with: {
cards: true,
},
});
}
async findById(id: string) {
return db.query.decks.findFirst({
where: eq(decks.id, id),
with: {
cards: {
orderBy: (cards, { asc }) => [asc(cards.position)],
},
},
});
}
async findPublicAndUserDecks(userId: string) {
return db.query.decks.findMany({
where: or(
eq(decks.userId, userId),
and(eq(decks.isPublic, true), eq(decks.isFeatured, true))
),
orderBy: desc(decks.updatedAt),
});
}
async create(data: NewDeck) {
const [deck] = await db.insert(decks).values(data).returning();
return deck;
}
async update(id: string, userId: string, data: Partial<NewDeck>) {
const [deck] = await db
.update(decks)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(decks.id, id), eq(decks.userId, userId)))
.returning();
return deck;
}
async delete(id: string, userId: string) {
await db.delete(decks).where(and(eq(decks.id, id), eq(decks.userId, userId)));
}
}
3.2 Service Layer aktualisieren
// services/deck.service.ts
import { DeckRepository } from '../repositories/deck.repository';
export class DeckService {
constructor(private deckRepo = new DeckRepository()) {}
async getUserDecks(userId: string) {
return this.deckRepo.findPublicAndUserDecks(userId);
}
async getDeck(id: string) {
return this.deckRepo.findById(id);
}
async createDeck(userId: string, data: CreateDeckInput) {
return this.deckRepo.create({
...data,
userId,
});
}
async updateDeck(id: string, userId: string, data: UpdateDeckInput) {
return this.deckRepo.update(id, userId, data);
}
async deleteDeck(id: string, userId: string) {
return this.deckRepo.delete(id, userId);
}
}
Phase 4: Frontend Migration (Tag 5-7)
4.1 API Client erstellen (ersetzt Supabase SDK)
// lib/api/client.ts
import { getToken } from '$lib/auth';
const API_URL = import.meta.env.VITE_API_URL;
async function fetchApi<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const token = getToken();
const response = await fetch(`${API_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...options.headers,
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'API Error');
}
return response.json();
}
export const api = {
get: <T>(endpoint: string) => fetchApi<T>(endpoint),
post: <T>(endpoint: string, data: unknown) =>
fetchApi<T>(endpoint, { method: 'POST', body: JSON.stringify(data) }),
put: <T>(endpoint: string, data: unknown) =>
fetchApi<T>(endpoint, { method: 'PUT', body: JSON.stringify(data) }),
delete: <T>(endpoint: string) => fetchApi<T>(endpoint, { method: 'DELETE' }),
};
4.2 Deck Store migrieren
// stores/deckStore.svelte.ts
import { api } from '$lib/api/client';
import type { Deck, CreateDeckInput, UpdateDeckInput } from '$lib/types/deck';
function createDeckStore() {
let decks = $state<Deck[]>([]);
let currentDeck = $state<Deck | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
return {
get decks() {
return decks;
},
get currentDeck() {
return currentDeck;
},
get loading() {
return loading;
},
get error() {
return error;
},
async fetchDecks() {
loading = true;
error = null;
try {
decks = await api.get<Deck[]>('/api/decks');
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch decks';
} finally {
loading = false;
}
},
async fetchDeck(id: string) {
loading = true;
error = null;
try {
currentDeck = await api.get<Deck>(`/api/decks/${id}`);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch deck';
} finally {
loading = false;
}
},
async createDeck(data: CreateDeckInput) {
loading = true;
error = null;
try {
const deck = await api.post<Deck>('/api/decks', data);
decks = [deck, ...decks];
return deck;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create deck';
throw e;
} finally {
loading = false;
}
},
async updateDeck(id: string, data: UpdateDeckInput) {
loading = true;
error = null;
try {
const deck = await api.put<Deck>(`/api/decks/${id}`, data);
decks = decks.map((d) => (d.id === id ? deck : d));
if (currentDeck?.id === id) currentDeck = deck;
return deck;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update deck';
throw e;
} finally {
loading = false;
}
},
async deleteDeck(id: string) {
loading = true;
error = null;
try {
await api.delete(`/api/decks/${id}`);
decks = decks.filter((d) => d.id !== id);
if (currentDeck?.id === id) currentDeck = null;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete deck';
throw e;
} finally {
loading = false;
}
},
};
}
export const deckStore = createDeckStore();
Phase 5: Testing & Cutover (Tag 7-10)
5.1 Integration Tests
// tests/deck.integration.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { db } from '@manadeck/database';
import { decks } from '@manadeck/database/schema';
import { DeckService } from '../services/deck.service';
describe('DeckService', () => {
const service = new DeckService();
const testUserId = 'test-user-id';
afterAll(async () => {
await db.delete(decks).where(eq(decks.userId, testUserId));
});
it('should create a deck', async () => {
const deck = await service.createDeck(testUserId, {
title: 'Test Deck',
description: 'A test deck',
});
expect(deck.id).toBeDefined();
expect(deck.title).toBe('Test Deck');
});
it('should fetch user decks', async () => {
const userDecks = await service.getUserDecks(testUserId);
expect(userDecks.length).toBeGreaterThan(0);
});
});
5.2 Datenmigrations-Script
// scripts/migrate-data.ts
import { db as newDb } from '@manadeck/database';
import { createClient } from '@supabase/supabase-js';
import { decks, cards } from '@manadeck/database/schema';
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!);
async function migrateDecks() {
console.log('Migrating decks...');
const { data: supabaseDecks, error } = await supabase.from('decks').select('*');
if (error) throw error;
for (const deck of supabaseDecks) {
await newDb
.insert(decks)
.values({
id: deck.id,
userId: deck.user_id,
title: deck.title,
description: deck.description,
coverImageUrl: deck.cover_image_url,
isPublic: deck.is_public,
isFeatured: deck.is_featured,
featuredAt: deck.featured_at,
settings: deck.settings,
tags: deck.tags,
metadata: deck.metadata,
createdAt: deck.created_at,
updatedAt: deck.updated_at,
})
.onConflictDoNothing();
}
console.log(`Migrated ${supabaseDecks.length} decks`);
}
async function migrateCards() {
console.log('Migrating cards...');
const { data: supabaseCards, error } = await supabase.from('cards').select('*');
if (error) throw error;
for (const card of supabaseCards) {
await newDb
.insert(cards)
.values({
id: card.id,
deckId: card.deck_id,
position: card.position,
title: card.title,
content: card.content,
cardType: card.card_type,
aiModel: card.ai_model,
aiPrompt: card.ai_prompt,
version: card.version,
isFavorite: card.is_favorite,
createdAt: card.created_at,
updatedAt: card.updated_at,
})
.onConflictDoNothing();
}
console.log(`Migrated ${supabaseCards.length} cards`);
}
async function main() {
try {
await migrateDecks();
await migrateCards();
// ... andere Tabellen
console.log('Migration completed successfully!');
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
}
}
main();
Zeitplan
| Phase | Beschreibung | Dauer | Status |
|---|---|---|---|
| 1 | Setup (PostgreSQL, Package) | 1-2 Tage | ⬜ Pending |
| 2 | Schema & Migration | 1-2 Tage | ⬜ Pending |
| 3 | Backend Migration | 2-3 Tage | ⬜ Pending |
| 4 | Frontend Migration | 2-3 Tage | ⬜ Pending |
| 5 | Testing & Cutover | 2-3 Tage | ⬜ Pending |
Gesamtdauer: ~10-13 Tage
Checkliste
Pre-Migration
- PostgreSQL Server aufgesetzt
- Database Package erstellt
- Drizzle Schema definiert
- Initial Migration durchgeführt
- Supabase Daten exportiert
Backend
- Repository Pattern implementiert
- DeckRepository
- CardRepository
- StudySessionRepository
- CardProgressRepository
- DeckTemplateRepository
- AIGenerationRepository
- UserStatsRepository
- Service Layer aktualisiert
- Controller aktualisiert
- Authorization Middleware (ersetzt RLS)
Frontend
- API Client erstellt
- deckStore migriert
- Supabase imports entfernt
- Environment Variables aktualisiert
Testing
- Unit Tests für Repositories
- Integration Tests für Services
- E2E Tests für API
- Manual Testing aller Features
Cutover
- Daten migriert (Script ausgeführt)
- DNS/Environment umgestellt
- Rollback Plan dokumentiert
- Monitoring eingerichtet
Rollback Plan
Falls kritische Probleme auftreten:
- Environment Variables zurücksetzen auf Supabase
- Feature Flag für alte Implementierung aktivieren
- DNS auf alten Service zeigen lassen
Supabase-Daten bleiben während der Migration unberührt.
Kosten nach Migration
| Service | Geschätzte Kosten |
|---|---|
| PostgreSQL (Railway) | ~$5-20/Monat |
| PostgreSQL (Neon Free) | $0/Monat |
| Backup (optional) | ~$5/Monat |
| Gesamt | $5-25/Monat |
vs. Supabase Pro: $25-300/Monat
Nächste Schritte
- Entscheidung: PostgreSQL Hosting (Railway vs Neon vs Self-hosted)
- Setup: Database Package im Monorepo erstellen
- Schema: Drizzle Schema implementieren
- Start: Phase 1 beginnen
Bereit zum Start?