Some checks are pending
CI / validate (push) Waiting to run
- 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>
303 lines
8 KiB
TypeScript
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();
|