db(cards): baseline migration + drizzle-tracking bootstrap script
Some checks are pending
CI / validate (push) Waiting to run

Schließt die Ops-Lücke „kein versioniertes Schema-Tracking" aus
FEATURE_IDEAS.md.

* apps/api/src/db/migrations/0000_baseline.sql — Drizzle-generierte
  Baseline-Migration, 355 Zeilen, 25 Tabellen + 5 Enums (cards- und
  marketplace-Schema). Eingefrostet auf den Live-Stand 2026-05-12.
* apps/api/scripts/bootstrap-drizzle-tracking.ts — neues Script,
  markiert die Baseline in einer bestehenden DB als „bereits
  angewandt", ohne SQL erneut auszuführen. Verwendet sha256 wie
  drizzle-orm/migrator (Hash 312d67ba1aeb…), idempotent.
* package.json: drizzle:migrate + drizzle:bootstrap-tracking
  npm-scripts.
* docs/playbooks/DRIZZLE_MIGRATIONS_BOOTSTRAP.md — Hand-Over für
  Prod (Bootstrap einmalig, dann normaler Workflow:
  schema → generate → commit → migrate, kein push --force mehr).

Lokal verifiziert: 17/104 Tests grün, bootstrap idempotent,
drizzle-kit migrate erkennt die Baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-12 18:53:52 +02:00
parent 5a29dd9a8c
commit 4bb1390180
7 changed files with 3523 additions and 5 deletions

View file

@ -14,8 +14,10 @@
"lint": "echo 'lint configured later (eslint flat-config)'",
"clean": "rm -rf dist .turbo coverage",
"drizzle:generate": "drizzle-kit generate",
"drizzle:migrate": "drizzle-kit migrate",
"drizzle:push": "drizzle-kit push --force",
"drizzle:studio": "drizzle-kit studio"
"drizzle:studio": "drizzle-kit studio",
"drizzle:bootstrap-tracking": "bun run scripts/bootstrap-drizzle-tracking.ts"
},
"dependencies": {
"@cards/domain": "workspace:*",

View file

@ -0,0 +1,90 @@
/**
* Bootstrap-Script für drizzle.__drizzle_migrations.
*
* Eine Cards-DB, die bisher über `drizzle-kit push` (Schema-Sync ohne
* Migrations-Tracking) gepflegt wurde, hat das Drizzle-Tracking-Schema
* nicht. Dieses Script holt das nach:
*
* 1. Erstellt `drizzle.__drizzle_migrations` (idempotent).
* 2. Liest `src/db/migrations/meta/_journal.json`.
* 3. Markiert jede dort gelistete Migration als bereits angewandt",
* mit dem gleichen Hash, den `drizzle-orm/migrator` selbst
* berechnen würde sha256(file_content) als hex.
*
* Nutzung:
* DATABASE_URL=postgresql://… pnpm bootstrap:drizzle
*
* Idempotent: ein zweiter Run macht nichts neu, sondern überspringt
* Migrations, deren Hash schon eingetragen ist.
*
* Nach dem Bootstrap wird `drizzle-kit migrate` jede Migration aus
* dem Journal als bekannt erkennen und keine SQL erneut ausführen.
* Künftige Schema-Änderungen `drizzle-kit generate` commit
* `drizzle-kit migrate` führt nur die neuen Migrations aus.
*/
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import postgres from 'postgres';
const url = process.env.DATABASE_URL;
if (!url) {
console.error('DATABASE_URL not set');
process.exit(1);
}
const here = dirname(fileURLToPath(import.meta.url));
const migrationsDir = resolve(here, '..', 'src', 'db', 'migrations');
const journalPath = resolve(migrationsDir, 'meta', '_journal.json');
interface JournalEntry {
idx: number;
version: string;
when: number;
tag: string;
breakpoints: boolean;
}
interface Journal {
version: string;
dialect: string;
entries: JournalEntry[];
}
const journal: Journal = JSON.parse(readFileSync(journalPath, 'utf-8'));
const sql = postgres(url, { max: 1 });
try {
await sql`CREATE SCHEMA IF NOT EXISTS drizzle`;
await sql`CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
id SERIAL PRIMARY KEY,
hash text NOT NULL,
created_at bigint
)`;
const existing = await sql<{ hash: string }[]>`SELECT hash FROM drizzle.__drizzle_migrations`;
const existingHashes = new Set(existing.map((r) => r.hash));
for (const entry of journal.entries) {
const migrationPath = resolve(migrationsDir, `${entry.tag}.sql`);
const fileContent = readFileSync(migrationPath, 'utf-8');
const hash = createHash('sha256').update(fileContent).digest('hex');
if (existingHashes.has(hash)) {
console.log(`SKIP ${entry.tag} (hash ${hash.slice(0, 12)}… already tracked)`);
continue;
}
await sql`INSERT INTO drizzle.__drizzle_migrations ("hash", "created_at")
VALUES (${hash}, ${entry.when})`;
console.log(`MARKED ${entry.tag} (hash ${hash.slice(0, 12)}…)`);
}
const final = await sql<{ count: number }[]>`SELECT COUNT(*)::int AS count
FROM drizzle.__drizzle_migrations`;
console.log(`\nTracking-Tabelle hat jetzt ${final[0]!.count} Eintrag/Einträge.`);
} finally {
await sql.end({ timeout: 5 });
}

View file

@ -0,0 +1,356 @@
CREATE SCHEMA "cards";
--> statement-breakpoint
CREATE SCHEMA "marketplace";
--> statement-breakpoint
CREATE TYPE "marketplace"."ai_mod_verdict" AS ENUM('pass', 'flag', 'block');--> statement-breakpoint
CREATE TYPE "marketplace"."card_type" AS ENUM('basic', 'basic-reverse', 'cloze', 'type-in', 'image-occlusion', 'audio', 'multiple-choice');--> statement-breakpoint
CREATE TYPE "marketplace"."pr_status" AS ENUM('open', 'merged', 'closed', 'rejected');--> statement-breakpoint
CREATE TYPE "marketplace"."report_category" AS ENUM('spam', 'copyright', 'nsfw', 'misinformation', 'hate', 'other');--> statement-breakpoint
CREATE TYPE "marketplace"."report_status" AS ENUM('open', 'dismissed', 'actioned');--> statement-breakpoint
CREATE TABLE "cards"."card_tags" (
"card_id" text NOT NULL,
"tag_id" text NOT NULL,
CONSTRAINT "card_tags_card_id_tag_id_pk" PRIMARY KEY("card_id","tag_id")
);
--> statement-breakpoint
CREATE TABLE "cards"."cards" (
"id" text PRIMARY KEY NOT NULL,
"deck_id" text NOT NULL,
"user_id" text NOT NULL,
"type" text NOT NULL,
"fields" jsonb NOT NULL,
"media_refs" jsonb DEFAULT '[]'::jsonb NOT NULL,
"content_hash" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cards"."decks" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"name" text NOT NULL,
"description" text,
"color" text,
"category" text,
"visibility" text DEFAULT 'private' NOT NULL,
"fsrs_settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
"content_hash" text,
"forked_from_marketplace_deck_id" text,
"forked_from_marketplace_version_id" text,
"archived_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cards"."import_jobs" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"source" text NOT NULL,
"state" text DEFAULT 'queued' NOT NULL,
"meta" jsonb,
"error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"finished_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "marketplace"."ai_moderation_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"version_id" uuid NOT NULL,
"verdict" "marketplace"."ai_mod_verdict" NOT NULL,
"categories" text[],
"model" text,
"rationale" text,
"human_reviewed" boolean DEFAULT false NOT NULL,
"human_overrode" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "marketplace"."author_follows" (
"follower_user_id" text NOT NULL,
"author_user_id" text NOT NULL,
"since" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "marketplace"."author_payouts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"author_user_id" text NOT NULL,
"source_purchase_id" uuid NOT NULL,
"credits_granted" integer NOT NULL,
"credits_grant_id" text,
"granted_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "marketplace"."authors" (
"user_id" text PRIMARY KEY NOT NULL,
"slug" text NOT NULL,
"display_name" text NOT NULL,
"bio" text,
"avatar_url" text,
"joined_at" timestamp with time zone DEFAULT now() NOT NULL,
"pseudonym" boolean DEFAULT false NOT NULL,
"verified_mana" boolean DEFAULT false NOT NULL,
"verified_community" boolean DEFAULT false NOT NULL,
"banned_at" timestamp with time zone,
"banned_reason" text
);
--> statement-breakpoint
CREATE TABLE "marketplace"."card_discussions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"card_content_hash" text NOT NULL,
"deck_id" uuid NOT NULL,
"author_user_id" text NOT NULL,
"parent_id" uuid,
"body" text NOT NULL,
"hidden" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "marketplace"."deck_forks" (
"user_id" text NOT NULL,
"source_deck_id" uuid NOT NULL,
"source_version_id" uuid NOT NULL,
"forked_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "marketplace"."deck_pull_requests" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"deck_id" uuid NOT NULL,
"author_user_id" text NOT NULL,
"status" "marketplace"."pr_status" DEFAULT 'open' NOT NULL,
"title" text NOT NULL,
"body" text,
"diff" jsonb NOT NULL,
"merged_into_version_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"resolved_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "marketplace"."deck_purchases" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"buyer_user_id" text NOT NULL,
"deck_id" uuid NOT NULL,
"version_id" uuid NOT NULL,
"price_credits" integer NOT NULL,
"author_share" integer NOT NULL,
"mana_share" integer NOT NULL,
"credits_transaction" text,
"purchased_at" timestamp with time zone DEFAULT now() NOT NULL,
"refunded_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "marketplace"."deck_reports" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"deck_id" uuid NOT NULL,
"version_id" uuid,
"card_content_hash" text,
"reporter_user_id" text NOT NULL,
"category" "marketplace"."report_category" NOT NULL,
"body" text,
"status" "marketplace"."report_status" DEFAULT 'open' NOT NULL,
"resolved_by" text,
"resolved_at" timestamp with time zone,
"resolution_notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "marketplace"."deck_stars" (
"user_id" text NOT NULL,
"deck_id" uuid NOT NULL,
"starred_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "marketplace"."deck_subscriptions" (
"user_id" text NOT NULL,
"deck_id" uuid NOT NULL,
"current_version_id" uuid,
"subscribed_at" timestamp with time zone DEFAULT now() NOT NULL,
"notify_updates" boolean DEFAULT true NOT NULL
);
--> statement-breakpoint
CREATE TABLE "marketplace"."deck_tags" (
"deck_id" uuid NOT NULL,
"tag_id" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cards"."media_files" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"object_key" text NOT NULL,
"mime_type" text NOT NULL,
"original_filename" text,
"size_bytes" integer NOT NULL,
"kind" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cards"."media_refs" (
"id" text PRIMARY KEY NOT NULL,
"card_id" text NOT NULL,
"user_id" text NOT NULL,
"mana_media_object_id" text NOT NULL,
"kind" text NOT NULL,
"ord" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "marketplace"."deck_cards" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"version_id" uuid NOT NULL,
"type" "marketplace"."card_type" NOT NULL,
"fields" jsonb NOT NULL,
"ord" integer NOT NULL,
"content_hash" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "marketplace"."deck_versions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"deck_id" uuid NOT NULL,
"semver" text NOT NULL,
"changelog" text,
"content_hash" text NOT NULL,
"card_count" integer NOT NULL,
"published_at" timestamp with time zone DEFAULT now() NOT NULL,
"deprecated_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "marketplace"."decks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"slug" text NOT NULL,
"title" text NOT NULL,
"description" text,
"language" text,
"category" text,
"license" text DEFAULT 'Cardecky-Personal-Use-1.0' NOT NULL,
"price_credits" integer DEFAULT 0 NOT NULL,
"owner_user_id" text NOT NULL,
"latest_version_id" uuid,
"is_featured" boolean DEFAULT false NOT NULL,
"is_takedown" boolean DEFAULT false NOT NULL,
"takedown_at" timestamp with time zone,
"takedown_reason" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "decks_price_requires_license" CHECK (price_credits = 0 OR license = 'Cardecky-Pro-Only-1.0')
);
--> statement-breakpoint
CREATE TABLE "cards"."reviews" (
"card_id" text NOT NULL,
"sub_index" integer DEFAULT 0 NOT NULL,
"user_id" text NOT NULL,
"due" timestamp with time zone NOT NULL,
"stability" real NOT NULL,
"difficulty" real NOT NULL,
"elapsed_days" real DEFAULT 0 NOT NULL,
"scheduled_days" real DEFAULT 0 NOT NULL,
"learning_steps" integer DEFAULT 0 NOT NULL,
"reps" integer DEFAULT 0 NOT NULL,
"lapses" integer DEFAULT 0 NOT NULL,
"state" text DEFAULT 'new' NOT NULL,
"last_review" timestamp with time zone,
CONSTRAINT "reviews_card_id_sub_index_pk" PRIMARY KEY("card_id","sub_index")
);
--> statement-breakpoint
CREATE TABLE "cards"."study_sessions" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"deck_id" text NOT NULL,
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
"finished_at" timestamp with time zone,
"cards_reviewed" integer DEFAULT 0 NOT NULL,
"cards_correct" integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
CREATE TABLE "marketplace"."tag_definitions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"slug" text NOT NULL,
"name" text NOT NULL,
"parent_id" uuid,
"description" text,
"curated" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cards"."tags" (
"id" text PRIMARY KEY NOT NULL,
"deck_id" text NOT NULL,
"user_id" text NOT NULL,
"name" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "cards"."card_tags" ADD CONSTRAINT "card_tags_card_id_cards_id_fk" FOREIGN KEY ("card_id") REFERENCES "cards"."cards"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cards"."cards" ADD CONSTRAINT "cards_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "cards"."decks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "marketplace"."ai_moderation_log" ADD CONSTRAINT "ai_moderation_log_version_id_deck_versions_id_fk" FOREIGN KEY ("version_id") REFERENCES "marketplace"."deck_versions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "marketplace"."author_follows" ADD CONSTRAINT "author_follows_author_user_id_authors_user_id_fk" FOREIGN KEY ("author_user_id") REFERENCES "marketplace"."authors"("user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "marketplace"."author_payouts" ADD CONSTRAINT "author_payouts_author_user_id_authors_user_id_fk" FOREIGN KEY ("author_user_id") REFERENCES "marketplace"."authors"("user_id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "marketplace"."author_payouts" ADD CONSTRAINT "author_payouts_source_purchase_id_deck_purchases_id_fk" FOREIGN KEY ("source_purchase_id") REFERENCES "marketplace"."deck_purchases"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "marketplace"."card_discussions" ADD CONSTRAINT "card_discussions_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "marketplace"."decks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "marketplace"."deck_forks" ADD CONSTRAINT "deck_forks_source_deck_id_decks_id_fk" FOREIGN KEY ("source_deck_id") REFERENCES "marketplace"."decks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "marketplace"."deck_forks" ADD CONSTRAINT "deck_forks_source_version_id_deck_versions_id_fk" FOREIGN KEY ("source_version_id") REFERENCES "marketplace"."deck_versions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "marketplace"."deck_pull_requests" ADD CONSTRAINT "deck_pull_requests_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "marketplace"."decks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "marketplace"."deck_pull_requests" ADD CONSTRAINT "deck_pull_requests_merged_into_version_id_deck_versions_id_fk" FOREIGN KEY ("merged_into_version_id") REFERENCES "marketplace"."deck_versions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "marketplace"."deck_purchases" ADD CONSTRAINT "deck_purchases_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "marketplace"."decks"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "marketplace"."deck_purchases" ADD CONSTRAINT "deck_purchases_version_id_deck_versions_id_fk" FOREIGN KEY ("version_id") REFERENCES "marketplace"."deck_versions"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "marketplace"."deck_reports" ADD CONSTRAINT "deck_reports_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "marketplace"."decks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "marketplace"."deck_reports" ADD CONSTRAINT "deck_reports_version_id_deck_versions_id_fk" FOREIGN KEY ("version_id") REFERENCES "marketplace"."deck_versions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "marketplace"."deck_stars" ADD CONSTRAINT "deck_stars_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "marketplace"."decks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "marketplace"."deck_subscriptions" ADD CONSTRAINT "deck_subscriptions_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "marketplace"."decks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "marketplace"."deck_subscriptions" ADD CONSTRAINT "deck_subscriptions_current_version_id_deck_versions_id_fk" FOREIGN KEY ("current_version_id") REFERENCES "marketplace"."deck_versions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "marketplace"."deck_tags" ADD CONSTRAINT "deck_tags_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "marketplace"."decks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "marketplace"."deck_tags" ADD CONSTRAINT "deck_tags_tag_id_tag_definitions_id_fk" FOREIGN KEY ("tag_id") REFERENCES "marketplace"."tag_definitions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cards"."media_refs" ADD CONSTRAINT "media_refs_card_id_cards_id_fk" FOREIGN KEY ("card_id") REFERENCES "cards"."cards"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "marketplace"."deck_cards" ADD CONSTRAINT "deck_cards_version_id_deck_versions_id_fk" FOREIGN KEY ("version_id") REFERENCES "marketplace"."deck_versions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "marketplace"."deck_versions" ADD CONSTRAINT "deck_versions_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "marketplace"."decks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "marketplace"."decks" ADD CONSTRAINT "decks_owner_user_id_authors_user_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "marketplace"."authors"("user_id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cards"."reviews" ADD CONSTRAINT "reviews_card_id_cards_id_fk" FOREIGN KEY ("card_id") REFERENCES "cards"."cards"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cards"."study_sessions" ADD CONSTRAINT "study_sessions_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "cards"."decks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cards"."tags" ADD CONSTRAINT "tags_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "cards"."decks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "cards_deck_idx" ON "cards"."cards" USING btree ("deck_id");--> statement-breakpoint
CREATE INDEX "cards_user_idx" ON "cards"."cards" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "decks_user_idx" ON "cards"."decks" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "imports_user_idx" ON "cards"."import_jobs" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "imports_state_idx" ON "cards"."import_jobs" USING btree ("state");--> statement-breakpoint
CREATE INDEX "ai_moderation_log_version_idx" ON "marketplace"."ai_moderation_log" USING btree ("version_id");--> statement-breakpoint
CREATE INDEX "ai_moderation_log_verdict_idx" ON "marketplace"."ai_moderation_log" USING btree ("verdict");--> statement-breakpoint
CREATE UNIQUE INDEX "author_follows_pk" ON "marketplace"."author_follows" USING btree ("follower_user_id","author_user_id");--> statement-breakpoint
CREATE INDEX "author_follows_author_idx" ON "marketplace"."author_follows" USING btree ("author_user_id");--> statement-breakpoint
CREATE INDEX "author_follows_follower_idx" ON "marketplace"."author_follows" USING btree ("follower_user_id");--> statement-breakpoint
CREATE INDEX "author_payouts_author_idx" ON "marketplace"."author_payouts" USING btree ("author_user_id");--> statement-breakpoint
CREATE INDEX "author_payouts_purchase_idx" ON "marketplace"."author_payouts" USING btree ("source_purchase_id");--> statement-breakpoint
CREATE UNIQUE INDEX "authors_slug_idx" ON "marketplace"."authors" USING btree ("slug");--> statement-breakpoint
CREATE INDEX "authors_verified_idx" ON "marketplace"."authors" USING btree ("verified_mana","verified_community");--> statement-breakpoint
CREATE INDEX "card_discussions_hash_idx" ON "marketplace"."card_discussions" USING btree ("card_content_hash");--> statement-breakpoint
CREATE INDEX "card_discussions_deck_idx" ON "marketplace"."card_discussions" USING btree ("deck_id");--> statement-breakpoint
CREATE INDEX "card_discussions_parent_idx" ON "marketplace"."card_discussions" USING btree ("parent_id");--> statement-breakpoint
CREATE UNIQUE INDEX "deck_forks_pk" ON "marketplace"."deck_forks" USING btree ("user_id","source_deck_id","source_version_id");--> statement-breakpoint
CREATE INDEX "deck_forks_source_idx" ON "marketplace"."deck_forks" USING btree ("source_deck_id");--> statement-breakpoint
CREATE INDEX "deck_pull_requests_deck_idx" ON "marketplace"."deck_pull_requests" USING btree ("deck_id");--> statement-breakpoint
CREATE INDEX "deck_pull_requests_status_idx" ON "marketplace"."deck_pull_requests" USING btree ("deck_id","status");--> statement-breakpoint
CREATE INDEX "deck_pull_requests_author_idx" ON "marketplace"."deck_pull_requests" USING btree ("author_user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "deck_purchases_buyer_deck_idx" ON "marketplace"."deck_purchases" USING btree ("buyer_user_id","deck_id");--> statement-breakpoint
CREATE INDEX "deck_purchases_buyer_idx" ON "marketplace"."deck_purchases" USING btree ("buyer_user_id");--> statement-breakpoint
CREATE INDEX "deck_purchases_deck_idx" ON "marketplace"."deck_purchases" USING btree ("deck_id");--> statement-breakpoint
CREATE INDEX "deck_reports_deck_idx" ON "marketplace"."deck_reports" USING btree ("deck_id");--> statement-breakpoint
CREATE INDEX "deck_reports_status_idx" ON "marketplace"."deck_reports" USING btree ("status");--> statement-breakpoint
CREATE UNIQUE INDEX "deck_stars_pk" ON "marketplace"."deck_stars" USING btree ("user_id","deck_id");--> statement-breakpoint
CREATE INDEX "deck_stars_deck_idx" ON "marketplace"."deck_stars" USING btree ("deck_id");--> statement-breakpoint
CREATE UNIQUE INDEX "deck_subscriptions_pk" ON "marketplace"."deck_subscriptions" USING btree ("user_id","deck_id");--> statement-breakpoint
CREATE INDEX "deck_subscriptions_deck_idx" ON "marketplace"."deck_subscriptions" USING btree ("deck_id");--> statement-breakpoint
CREATE INDEX "deck_subscriptions_user_idx" ON "marketplace"."deck_subscriptions" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "deck_tags_pk" ON "marketplace"."deck_tags" USING btree ("deck_id","tag_id");--> statement-breakpoint
CREATE INDEX "deck_tags_tag_idx" ON "marketplace"."deck_tags" USING btree ("tag_id");--> statement-breakpoint
CREATE INDEX "media_files_user_idx" ON "cards"."media_files" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "media_card_idx" ON "cards"."media_refs" USING btree ("card_id");--> statement-breakpoint
CREATE UNIQUE INDEX "deck_cards_version_ord_idx" ON "marketplace"."deck_cards" USING btree ("version_id","ord");--> statement-breakpoint
CREATE INDEX "deck_cards_hash_idx" ON "marketplace"."deck_cards" USING btree ("content_hash");--> statement-breakpoint
CREATE UNIQUE INDEX "deck_versions_deck_semver_idx" ON "marketplace"."deck_versions" USING btree ("deck_id","semver");--> statement-breakpoint
CREATE INDEX "deck_versions_deck_idx" ON "marketplace"."deck_versions" USING btree ("deck_id");--> statement-breakpoint
CREATE INDEX "deck_versions_hash_idx" ON "marketplace"."deck_versions" USING btree ("content_hash");--> statement-breakpoint
CREATE UNIQUE INDEX "decks_slug_idx" ON "marketplace"."decks" USING btree ("slug");--> statement-breakpoint
CREATE INDEX "decks_owner_idx" ON "marketplace"."decks" USING btree ("owner_user_id");--> statement-breakpoint
CREATE INDEX "decks_featured_idx" ON "marketplace"."decks" USING btree ("is_featured");--> statement-breakpoint
CREATE INDEX "reviews_user_due_idx" ON "cards"."reviews" USING btree ("user_id","due");--> statement-breakpoint
CREATE INDEX "sessions_user_started_idx" ON "cards"."study_sessions" USING btree ("user_id","started_at");--> statement-breakpoint
CREATE UNIQUE INDEX "tag_definitions_slug_idx" ON "marketplace"."tag_definitions" USING btree ("slug");--> statement-breakpoint
CREATE INDEX "tag_definitions_parent_idx" ON "marketplace"."tag_definitions" USING btree ("parent_id");--> statement-breakpoint
CREATE INDEX "tags_deck_idx" ON "cards"."tags" USING btree ("deck_id");--> statement-breakpoint
CREATE UNIQUE INDEX "tags_deck_name_uniq" ON "cards"."tags" USING btree ("deck_id","name");

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1778604624860,
"tag": "0000_baseline",
"breakpoints": true
}
]
}

View file

@ -363,10 +363,16 @@ Bisher gar nicht in dieser Liste behandelt, aber Cards ist live:
E2E-Smoke"; ein `playwright.config.ts` existiert im Repo nicht.
Bei Live-App mit Marketplace-Forks die schmerzhafteste
Regressions-Quelle.
- **Drizzle-Migrationen versionieren**`drizzle.config.ts` zeigt
auf `out: './src/db/migrations'`, der Ordner existiert aber nicht
(nur `db:push`). Mit Prod-Daten ein Risiko; Wechsel auf
`drizzle-kit generate` + versionierte Migrationen.
- **Drizzle-Migrationen versionieren — Repo-Setup gebaut 2026-05-12,
Prod-Bootstrap offen.** `0000_baseline.sql` (355 Zeilen, 25 Tabellen
+ 5 Enums) festgefrostet. `apps/api/scripts/bootstrap-drizzle-tracking.ts`
+ `pnpm drizzle:bootstrap-tracking` markiert die Baseline auf der
Live-DB als „bereits angewandt", ohne SQL re-auszuführen. Lokal
verifiziert: idempotent + `drizzle:migrate` erkennt die Migration
danach als bekannt. Playbook in
`docs/playbooks/DRIZZLE_MIGRATIONS_BOOTSTRAP.md`. Nächster Schritt
beim User: einmalig auf der Prod-Box ausführen, danach ist `push
--force` Tabu.
- **Storybook / Histoire für `lib/components/`** — 20+ Svelte-
Komponenten ohne isolierte Vorschau; Lost-Pixel-Theming-Strategie
würde davon doppelt profitieren.

View file

@ -0,0 +1,155 @@
# Drizzle-Migrationen — Bootstrap und Workflow
Stand: 2026-05-12.
Cards hat bis 2026-05-12 alle Schema-Änderungen über
`drizzle-kit push --force` gefahren — Schema-Sync ohne versionierte
Migrationen. Live ist das gefährlich: kein Audit-Trail, keine
sichere Rollback-Story, schwer reviewable.
Ab Commit `<dieser>` gibt es eine **versionierte Migration-Welt** mit
einer Baseline (`0000_baseline.sql`), die das bestehende Schema
festfriert.
---
## Was lokal schon passiert ist
1. `pnpm drizzle:generate``apps/api/src/db/migrations/0000_baseline.sql`
(25 Tabellen, 5 Enums, alle FKs/Indizes — das gesamte cards- und
marketplace-Schema, eingefroren auf den Stand 2026-05-12).
2. `apps/api/scripts/bootstrap-drizzle-tracking.ts` (neues npm-script
`pnpm drizzle:bootstrap-tracking`) — markiert in einer existierenden
Live-DB die Baseline als „bereits angewandt", ohne SQL erneut
auszuführen.
3. Lokal verifiziert: Bootstrap ist idempotent, `pnpm drizzle:migrate`
erkennt die Migration danach als bekannt.
---
## Was du auf der Prod-Box machen musst (einmalig)
```bash
ssh mana-server
cd ~/projects/cards
git pull --ff-only origin main
```
### 1. Bootstrap auf der Live-DB ausführen
```bash
# DATABASE_URL aus der env-Datei lesen, nicht hart codieren.
DB_PW=$(grep CARDS_DB_PASSWORD infrastructure/.env.production | cut -d= -f2-)
docker exec -e DATABASE_URL="postgresql://cards:${DB_PW}@cards-postgres:5432/cards" \
cards-api node /app/apps/api/scripts/bootstrap-drizzle-tracking.ts
```
…**oder** wenn das im Container-Image-Setup nicht klappt (Script ist
TypeScript, der API-Container kommt mit Bun):
```bash
# Alternative: vom Host aus über Container-Network ausführen.
# Mac Mini hat Bun installiert.
cd ~/projects/cards/apps/api
DATABASE_URL="postgresql://cards:${DB_PW}@127.0.0.1:5436/cards" \
bun run scripts/bootstrap-drizzle-tracking.ts
```
**Erwartete Ausgabe:**
```
MARKED 0000_baseline (hash 312d67ba1aeb…)
Tracking-Tabelle hat jetzt 1 Eintrag/Einträge.
```
Wenn statt `MARKED` schon `SKIP` kommt: jemand hat das Script bereits
gelaufen lassen. Idempotent → kein Problem.
### 2. Verifizieren
```bash
docker exec cards-postgres psql -U cards -d cards -c \
"SELECT * FROM drizzle.__drizzle_migrations;"
```
Erwartete Antwort: eine Zeile mit Hash `312d67ba1aeb…` und
`created_at = 1778604624860`.
### 3. Smoke-Test
```bash
cd ~/projects/cards/apps/api
DATABASE_URL="postgresql://cards:${DB_PW}@127.0.0.1:5436/cards" \
bun x drizzle-kit migrate
```
Erwartet: `[✓] migrations applied successfully!` ohne dass das
Schema wirklich angefasst wird (es kommt nur ein NOTICE
„relation '__drizzle_migrations' already exists, skipping").
---
## Künftiger Workflow
### Neue Schema-Änderung
1. **Editiere das Schema** in `apps/api/src/db/schema/**.ts`.
2. **Generiere die Migration** lokal:
```bash
cd apps/api
pnpm drizzle:generate --name <kurz_beschreibend>
```
3. **Inspiziere** das generierte SQL in `src/db/migrations/0001_<name>.sql`
(oder höher) — bei destruktiven Änderungen (DROP COLUMN, ALTER
TYPE) ggf. von Hand patchen, damit Daten nicht verloren gehen.
4. **Commit** Schema-Files + Migration-Files + Journal zusammen:
```bash
git add apps/api/src/db/schema apps/api/src/db/migrations
git commit -m "db(cards): <was>"
```
5. **Test lokal**:
```bash
DATABASE_URL="postgresql://cards:cards@localhost:5435/cards" \
pnpm drizzle:migrate
```
### Deploy
1. `git push` auf Forgejo.
2. Auf der Box: `git pull --ff-only`.
3. **Migration anwenden** (kein push --force mehr!):
```bash
cd ~/projects/cards/apps/api
DATABASE_URL="postgresql://cards:${DB_PW}@127.0.0.1:5436/cards" \
bun x drizzle-kit migrate
```
4. Erst danach Container rebuilden + restarten, wenn der neue Code
auf das neue Schema angewiesen ist.
---
## Tabu
- **`drizzle-kit push --force` auf Prod nie wieder.** Das Script
bleibt in `package.json` als Dev-Convenience, aber für Prod ist
nur `drizzle-kit migrate` der Weg.
- **Migration-Files nicht nachträglich umschreiben**, sobald sie
in einem Prod-Pull-Stand sind — der Hash würde sich ändern und
Drizzle würde sie erneut anwenden wollen.
- **Wenn du das Schema lokal mit `push` schon „weiter" als die
Migration hast**, ist das ein Drift-Bug. Lösung: lokal die DB
rückbauen (`drop schema cards cascade; drop schema marketplace cascade`),
dann Migrationen frisch anwenden. Niemals diesen Drift in einen
Commit hineinwachsen lassen.
---
## Bei Problemen
- `pnpm drizzle:migrate` schmeißt „relation already exists" und
bricht ab? Bootstrap-Script ist nie gelaufen — Schritt 1 oben.
- Hash-Mismatch (Migration angeblich „neu" trotz Bootstrap)? Das
Migration-File wurde nach dem Bootstrap noch ediert. Entweder
Edit rückgängig oder den Tracking-Eintrag von Hand auf den neuen
Hash bringen — ausschließlich nach Code-Review.