style: auto-format codebase with Prettier

Applied formatting to 1487+ files using pnpm format:write
  - TypeScript/JavaScript files
  - Svelte components
  - Astro pages
  - JSON configs
  - Markdown docs

  13 files still need manual review (Astro JSX comments)
This commit is contained in:
Wuesteon 2025-11-27 18:33:16 +01:00
parent 0241f5554c
commit d36b321d9d
3952 changed files with 661498 additions and 739751 deletions

View file

@ -63,6 +63,7 @@ Dieses Dokument beschreibt die Migration von ManaDeck von Supabase zu einer selb
### Tabellen
#### 1. `decks`
```sql
CREATE TABLE decks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@ -86,6 +87,7 @@ CREATE INDEX idx_decks_is_featured ON decks(is_featured);
```
#### 2. `cards`
```sql
CREATE TABLE cards (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@ -107,6 +109,7 @@ CREATE INDEX idx_cards_position ON cards(deck_id, position);
```
#### 3. `study_sessions`
```sql
CREATE TABLE study_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@ -126,6 +129,7 @@ CREATE INDEX idx_study_sessions_deck_id ON study_sessions(deck_id);
```
#### 4. `card_progress`
```sql
CREATE TABLE card_progress (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@ -147,6 +151,7 @@ CREATE INDEX idx_card_progress_next_review ON card_progress(next_review);
```
#### 5. `deck_templates`
```sql
CREATE TABLE deck_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@ -166,6 +171,7 @@ CREATE INDEX idx_deck_templates_is_active ON deck_templates(is_active);
```
#### 6. `ai_generations`
```sql
CREATE TABLE ai_generations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@ -185,6 +191,7 @@ CREATE INDEX idx_ai_generations_status ON ai_generations(status);
```
#### 7. `user_stats` (für Leaderboard)
```sql
CREATE TABLE user_stats (
user_id UUID PRIMARY KEY,
@ -233,23 +240,24 @@ manadeck/
### Schema-Definitionen (Drizzle)
#### `schema/decks.ts`
```typescript
import { pgTable, uuid, varchar, text, boolean, timestamp, jsonb } from 'drizzle-orm/pg-core';
export const decks = pgTable('decks', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
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(),
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
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;
@ -257,25 +265,37 @@ export type NewDeck = typeof decks.$inferInsert;
```
#### `schema/cards.ts`
```typescript
import { pgTable, uuid, varchar, text, integer, boolean, timestamp, jsonb } from 'drizzle-orm/pg-core';
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(),
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;
@ -283,6 +303,7 @@ export type NewCard = typeof cards.$inferInsert;
```
#### `schema/index.ts`
```typescript
export * from './decks';
export * from './cards';
@ -300,6 +321,7 @@ export { cardsRelations } from './cards';
### Client Setup
#### `client.ts`
```typescript
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
@ -309,9 +331,9 @@ const connectionString = process.env.DATABASE_URL!;
// For connection pooling in serverless environments
const client = postgres(connectionString, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
export const db = drizzle(client, { schema });
@ -325,6 +347,7 @@ export type Database = typeof db;
### Phase 1: Setup (Tag 1-2)
#### 1.1 PostgreSQL Server aufsetzen
```bash
# Option A: Railway.app (empfohlen für Staging)
# Erstelle neues Projekt auf railway.app
@ -344,6 +367,7 @@ docker run -d \
```
#### 1.2 Database Package erstellen
```bash
cd /Users/tillschneider/Documents/__00__Code/manacore-monorepo
mkdir -p packages/manadeck-database
@ -352,6 +376,7 @@ pnpm init
```
#### 1.3 Dependencies installieren
```bash
pnpm add drizzle-orm postgres
pnpm add -D drizzle-kit typescript @types/node
@ -360,17 +385,20 @@ 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
```bash
pnpm drizzle-kit generate
pnpm drizzle-kit migrate
```
#### 2.3 Daten von Supabase exportieren
```bash
# In Supabase Dashboard: SQL Editor
# Export alle Tabellen als CSV oder pg_dump
@ -401,56 +429,54 @@ 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 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 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 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 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 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)));
}
async delete(id: string, userId: string) {
await db.delete(decks).where(and(eq(decks.id, id), eq(decks.userId, userId)));
}
}
```
@ -461,30 +487,30 @@ export class DeckRepository {
import { DeckRepository } from '../repositories/deck.repository';
export class DeckService {
constructor(private deckRepo = new DeckRepository()) {}
constructor(private deckRepo = new DeckRepository()) {}
async getUserDecks(userId: string) {
return this.deckRepo.findPublicAndUserDecks(userId);
}
async getUserDecks(userId: string) {
return this.deckRepo.findPublicAndUserDecks(userId);
}
async getDeck(id: string) {
return this.deckRepo.findById(id);
}
async getDeck(id: string) {
return this.deckRepo.findById(id);
}
async createDeck(userId: string, data: CreateDeckInput) {
return this.deckRepo.create({
...data,
userId,
});
}
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 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);
}
async deleteDeck(id: string, userId: string) {
return this.deckRepo.delete(id, userId);
}
}
```
@ -498,37 +524,33 @@ 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();
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,
},
});
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');
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'API Error');
}
return response.json();
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' }),
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' }),
};
```
@ -540,87 +562,95 @@ 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);
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; },
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 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 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 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 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;
}
},
};
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();
@ -629,6 +659,7 @@ export const deckStore = createDeckStore();
### Phase 5: Testing & Cutover (Tag 7-10)
#### 5.1 Integration Tests
```typescript
// tests/deck.integration.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
@ -637,111 +668,111 @@ import { decks } from '@manadeck/database/schema';
import { DeckService } from '../services/deck.service';
describe('DeckService', () => {
const service = new DeckService();
const testUserId = 'test-user-id';
const service = new DeckService();
const testUserId = 'test-user-id';
afterAll(async () => {
await db.delete(decks).where(eq(decks.userId, testUserId));
});
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',
});
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');
});
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);
});
it('should fetch user decks', async () => {
const userDecks = await service.getUserDecks(testUserId);
expect(userDecks.length).toBeGreaterThan(0);
});
});
```
#### 5.2 Datenmigrations-Script
```typescript
// 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!
);
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!);
async function migrateDecks() {
console.log('Migrating decks...');
console.log('Migrating decks...');
const { data: supabaseDecks, error } = await supabase
.from('decks')
.select('*');
const { data: supabaseDecks, error } = await supabase.from('decks').select('*');
if (error) throw error;
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();
}
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`);
console.log(`Migrated ${supabaseDecks.length} decks`);
}
async function migrateCards() {
console.log('Migrating cards...');
console.log('Migrating cards...');
const { data: supabaseCards, error } = await supabase
.from('cards')
.select('*');
const { data: supabaseCards, error } = await supabase.from('cards').select('*');
if (error) throw error;
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();
}
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`);
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);
}
try {
await migrateDecks();
await migrateCards();
// ... andere Tabellen
console.log('Migration completed successfully!');
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
}
}
main();
@ -751,13 +782,13 @@ 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 |
| 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**
@ -766,6 +797,7 @@ main();
## Checkliste
### Pre-Migration
- [ ] PostgreSQL Server aufgesetzt
- [ ] Database Package erstellt
- [ ] Drizzle Schema definiert
@ -773,6 +805,7 @@ main();
- [ ] Supabase Daten exportiert
### Backend
- [ ] Repository Pattern implementiert
- [ ] DeckRepository
- [ ] CardRepository
@ -786,18 +819,21 @@ main();
- [ ] 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
@ -819,12 +855,12 @@ 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** |
| 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