wordeck/apps/api/scripts/migrate-db-to-events.ts
Till JS cba37c3c37
Some checks are pending
CI / validate (push) Waiting to run
fix(wordeck): pre-existing test drifts + L-2 cleanup vor Deploy
- tests cards→wordeck rebrand-drift: app-name in health/search/tools/dsgvo,
  envelope to.app + service-key env-var WORDECK_DSGVO_SERVICE_KEY
  (war: CARDS_*). Test-Suite jetzt 83/83 grün.
- dsgvo.ts: ENV-Name auf WORDECK_DSGVO_SERVICE_KEY (war CARDS_*) — passt
  zum Test-Setup + wordeck-Branding
- decks.ts (web): generateDeckFromImage routet URL-only-Pfad auf
  generateDeck, File-Upload-Pfad wirft klaren Fehler (Server-Route
  existiert nicht). UI-Komponenten unverändert
- migrate-db-to-events.ts: Stub als „nicht benötigt" markiert.
  Wordeck-Production hat keine User-Daten in den obsoleten Tabellen;
  Marketplace-Decks (cardecky-User) leben in eigenem pgSchema und
  sind vom Cutover nicht betroffen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:56:42 +02:00

303 lines
8 KiB
TypeScript

/**
* Migration-Skript-Stub für DB→Event-Sync (L-2, 2026-05-20).
*
* **Aktueller Status (2026-05-20): NICHT BENÖTIGT.**
*
* Wordeck-Production hat keine User-Daten in den (jetzt obsoleten)
* wordeck.decks/cards/reviews-Tabellen. Die einzigen aktiven Daten
* sind die öffentlichen Marketplace-Decks des cardecky-Plattform-Users,
* die in `marketplace.public_decks` + `marketplace.deck_versions` leben
* und vom Big-Bang nicht betroffen sind.
*
* Dieses Skript bleibt als Bauplan stehen, falls künftig vor einem
* weiteren Cutover (z.B. Schema-V2-Migration) ein DB→Events-Lift
* gebraucht wird. **Bei Reaktivierung dringend Code-Review +
* Dry-Run-Tests, das Skript ist heute ungetestet.**
*
* Offene Implementation-Punkte falls Reaktivierung kommt:
* - `mintToken()` braucht entweder Service-Key-Token-Mint in mana-auth
* (gibt's heute nicht) oder Service-Key-Auth-Mode in mana-sync
* (gibt's heute nicht) — beides Plattform-Arbeit
* - Encryption: Plaintext-Migration ist akzeptabel solange Trust-
* Domain stimmt; Re-Encrypt nach User-Login als Folge-Task
*
* Verwendung (sobald aktiviert):
*
* pnpm tsx scripts/migrate-db-to-events.ts --dry-run [--user-id <id>]
* pnpm tsx scripts/migrate-db-to-events.ts --commit [--user-id <id>]
*
* Voraussetzungen:
* - DATABASE_URL gesetzt (Wordeck-DB)
* - MANA_SYNC_URL (default https://sync2.mana.how)
* - MANA_SERVICE_KEY (für Token-Mint)
*/
import { eq, sql } from 'drizzle-orm';
import { getDb } from '../src/db/connection.ts';
import { cards, decks, reviews } from '../src/db/schema/index.ts';
const SYNC_URL = process.env.MANA_SYNC_URL ?? 'https://sync2.mana.how';
const APP_ID = 'wordeck';
interface Args {
dryRun: boolean;
userId?: string;
}
function parseArgs(): Args {
const args = process.argv.slice(2);
const dryRun = !args.includes('--commit');
const uIdx = args.indexOf('--user-id');
const userId = uIdx >= 0 ? args[uIdx + 1] : undefined;
return { dryRun, userId };
}
interface EventEnvelope {
eventId: string;
aggregateId: string;
appId: string;
eventType: string;
eventVersion: number;
occurredAt: string;
actor: { kind: 'migration'; principalId: string; displayName: string };
attributedToUserId: string;
origin: 'migration';
idempotencyKey: string;
payload: Record<string, unknown>;
}
function ulid(): string {
// Crypto-Random ULID-light für Migration. Echtes ulid-Format wäre
// schöner, aber für migration-events reicht ein eindeutiger String.
return (
Math.floor(Date.now()).toString(36).padStart(8, '0').toUpperCase() +
crypto.randomUUID().replace(/-/g, '').slice(0, 16).toUpperCase()
);
}
function envelopeFor(
userId: string,
aggregateId: string,
eventType: string,
idemSuffix: string,
payload: Record<string, unknown>,
occurredAt: Date,
): EventEnvelope {
return {
eventId: ulid(),
aggregateId,
appId: APP_ID,
eventType,
eventVersion: 1,
occurredAt: occurredAt.toISOString(),
actor: {
kind: 'migration',
principalId: 'migration:wordeck:2026-05-20',
displayName: 'L-2 DB→Event-Sync Migration',
},
attributedToUserId: userId,
origin: 'migration',
idempotencyKey: `migration:${idemSuffix}`,
payload,
};
}
async function mintToken(userId: string): Promise<string> {
// Stub: Service-Key-basierter Mint. Implementation hängt davon ab,
// wie mana-auth das anbietet. Wenn nicht vorhanden, müssen wir
// einen anderen Pfad finden (z.B. sync2 mit Service-Key-Auth-Mode
// erweitern, was eigene Plattform-Arbeit ist).
throw new Error(
`mintToken stub: implementiere Service-Key-basierten JWT-Mint für ${userId}`,
);
}
async function postBatch(token: string, events: EventEnvelope[]): Promise<void> {
const res = await fetch(`${SYNC_URL}/sync/${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ events }),
});
if (!res.ok) {
throw new Error(`sync2 returned ${res.status}: ${await res.text()}`);
}
}
async function migrateUser(userId: string, dryRun: boolean): Promise<void> {
const db = getDb();
const deckRows = await db.select().from(decks).where(eq(decks.userId, userId));
const cardRows = await db.select().from(cards).where(eq(cards.userId, userId));
const reviewRows = await db.select().from(reviews).where(eq(reviews.userId, userId));
const events: EventEnvelope[] = [];
for (const d of deckRows) {
events.push(
envelopeFor(
userId,
`deck:${d.id}`,
'DeckCreated',
`deck:${d.id}:created`,
{
deckId: d.id,
name: d.name,
description: d.description,
color: d.color,
category: d.category,
},
d.createdAt,
),
);
if (d.fsrsSettings && Object.keys(d.fsrsSettings).length > 0) {
events.push(
envelopeFor(
userId,
`deck:${d.id}`,
'DeckFsrsSettingsUpdated',
`deck:${d.id}:fsrs`,
{ deckId: d.id, newSettingsJson: JSON.stringify(d.fsrsSettings) },
d.updatedAt,
),
);
}
if (d.visibility !== 'private') {
events.push(
envelopeFor(
userId,
`deck:${d.id}`,
'DeckPublished',
`deck:${d.id}:published`,
{ deckId: d.id, visibility: d.visibility, license: 'CC-BY-4.0' },
d.updatedAt,
),
);
}
if (d.archivedAt) {
events.push(
envelopeFor(
userId,
`deck:${d.id}`,
'DeckArchived',
`deck:${d.id}:archived`,
{ deckId: d.id },
d.archivedAt,
),
);
}
}
for (const card of cardRows) {
events.push(
envelopeFor(
userId,
`card:${card.id}`,
'CardCreated',
`card:${card.id}:created`,
{
cardId: card.id,
deckId: card.deckId,
type: card.type,
fieldsJson: JSON.stringify(card.fields),
tags: [],
},
card.createdAt,
),
);
}
for (const r of reviewRows) {
const aggId = `review:${r.cardId}__${r.subIndex}`;
events.push(
envelopeFor(
userId,
aggId,
'ReviewInitialized',
`review:${r.cardId}:${r.subIndex}:init`,
{
reviewId: aggId,
cardId: r.cardId,
subIndex: r.subIndex,
due: r.due.toISOString(),
},
r.lastReview ?? r.due,
),
);
if (r.state !== 'new' || r.reps > 0) {
// Aktueller State als ReviewGraded mit "rating=good" Sentinel-Wert.
// FSRS-Felder werden 1:1 übernommen. Nach Migration kann der User
// weiter graden, neue Events stacken sich auf.
events.push(
envelopeFor(
userId,
aggId,
'ReviewGraded',
`review:${r.cardId}:${r.subIndex}:state`,
{
reviewId: aggId,
rating: 'good',
newState: r.state,
newDue: r.due.toISOString(),
newStability: r.stability,
newDifficulty: r.difficulty,
newElapsedDays: r.elapsedDays,
newScheduledDays: r.scheduledDays,
newLearningSteps: r.learningSteps,
newReps: r.reps,
newLapses: r.lapses,
prevSnapshotJson: null,
},
r.lastReview ?? r.due,
),
);
}
}
console.log(
`[user ${userId}] decks=${deckRows.length} cards=${cardRows.length} reviews=${reviewRows.length}${events.length} events`,
);
if (dryRun) return;
const token = await mintToken(userId);
// Batches à 100 wegen sync2-server-limit
for (let i = 0; i < events.length; i += 100) {
await postBatch(token, events.slice(i, i + 100));
}
console.log(`[user ${userId}] migration applied`);
}
async function main(): Promise<void> {
const { dryRun, userId } = parseArgs();
console.log(`migration: dry-run=${dryRun} userId=${userId ?? '(all)'}`);
const db = getDb();
let userIds: string[];
if (userId) {
userIds = [userId];
} else {
const rows = await db.execute<{ user_id: string }>(
sql`SELECT DISTINCT user_id FROM wordeck.decks`,
);
userIds = rows.rows.map((r) => r.user_id);
}
console.log(`migration: ${userIds.length} user(s)`);
let ok = 0;
let fail = 0;
for (const uid of userIds) {
try {
await migrateUser(uid, dryRun);
ok++;
} catch (e) {
console.error(`[user ${uid}] FAILED:`, e instanceof Error ? e.message : e);
fail++;
}
}
console.log(`migration: ok=${ok} fail=${fail}`);
process.exit(fail > 0 ? 1 : 0);
}
void main();