feat(scripts): Migration der app-eigenen Daten aus der Monolith-DB

Bekommt die App eine eigene MongoDB, sind vier Dinge am ersten Tag weg:
Lernfortschritt, Pilotenprofil, Bridge-Kopplungen und der Identitäts-Spiegel,
auf den die drei verweisen. Das Skript holt genau die — und sonst nichts.

`appusers` wird abgeleitet statt kopiert: der Spiegel behält `_id` der
Website-`User`, weshalb die vorhandenen Referenzen unverändert weitergelten.
Passwort-Hashes, Reset-Tokens, Einladungen und Admin-Notizen bleiben drüben.

Dry-run per Default, jeder Schreibvorgang ein Upsert auf `_id`, die Quelle wird
nur gelesen. Quelle == Ziel wird abgelehnt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-07-28 08:20:37 +02:00
parent d3ece05ad9
commit d3531d73a8

View File

@@ -0,0 +1,161 @@
#!/usr/bin/env node
/**
* One-time migration: carry the app's own data out of the old monolith database
* into this instance's database.
*
* The repo split gave the app its own MongoDB. Four things live app-side and
* would otherwise be gone on day one: every user's classroom progress, their
* pilot profile, their bridge pairings, and the identity mirror those three
* reference. Everything else — users, invitations, waitlist, telemetry history —
* stays on the website and is deliberately NOT copied.
*
* `appusers` is derived, not copied: the mirror keeps `_id` identical to the
* website's `User._id` (see server/models/AppUser.ts), which is exactly why the
* LearnProfile/PilotProfile/BridgeToken references survive untouched.
*
* Safe to re-run: every write is an upsert keyed on `_id`. Nothing is ever
* deleted, and the source database is only ever read.
*
* Usage:
* node scripts/migrate-from-website-db.mjs # dry run, reports only
* node scripts/migrate-from-website-db.mjs --apply # actually writes
*
* SOURCE_MONGODB_URI the monolith / website database (read-only here)
* TARGET_MONGODB_URI this app instance's database
*/
import { MongoClient } from 'mongodb'
const APPLY = process.argv.includes('--apply')
const BATCH = 500
/** Copied verbatim, keyed on _id. */
const COPY_COLLECTIONS = ['learnprofiles', 'pilotprofiles', 'bridgetokens']
function readUri(name) {
const value = (process.env[name] || '').trim()
if (!value) {
console.error(`${name} is not set.`)
process.exit(1)
}
return value
}
/** host+db, so we can refuse to migrate a database onto itself. */
function identity(client) {
const db = client.db()
const hosts = (client.options?.hosts || []).map((h) => `${h.host}:${h.port}`).sort().join(',')
return `${hosts}/${db.databaseName}`
}
async function countExisting(collection, ids) {
if (ids.length === 0) return 0
return collection.countDocuments({ _id: { $in: ids } })
}
async function upsertAll(collection, documents) {
let written = 0
for (let i = 0; i < documents.length; i += BATCH) {
const chunk = documents.slice(i, i + BATCH)
const result = await collection.bulkWrite(
chunk.map((doc) => {
const { _id, ...rest } = doc
return { updateOne: { filter: { _id }, update: { $set: rest }, upsert: true } }
}),
{ ordered: false },
)
written += result.upsertedCount + result.modifiedCount
}
return written
}
/**
* The website's User row reduced to what the app is allowed to know: an id, a
* subject, and the contact fields the app shows. Password hashes, tokens, terms
* timestamps and admin notes stay on the website — the app has no business
* holding them.
*/
function toAppUser(user) {
// The raw driver bypasses Mongoose casting, so apply the schema's own
// `lowercase`/`trim` here — otherwise a migrated row would differ from the
// exact same row written through the app.
const name = typeof user.name === 'string' ? user.name.trim() : ''
return {
_id: user._id,
ssoSubject: String(user._id),
email: String(user.email || '').trim().toLowerCase(),
...(name ? { name } : {}),
role: user.role || 'user',
createdAt: user.createdAt || new Date(),
// Derived from the source row, not from "now" — otherwise a second run
// would rewrite every mirror document and report phantom changes.
updatedAt: user.updatedAt || user.createdAt || new Date(),
}
}
async function main() {
const source = new MongoClient(readUri('SOURCE_MONGODB_URI'))
const target = new MongoClient(readUri('TARGET_MONGODB_URI'))
await source.connect()
await target.connect()
try {
if (identity(source) === identity(target)) {
console.error('✗ Source and target are the same database — nothing to migrate.')
process.exitCode = 1
return
}
const sourceDb = source.db()
const targetDb = target.db()
console.log(`source: ${identity(source)} (read-only)`)
console.log(`target: ${identity(target)}`)
console.log(APPLY ? 'mode: APPLY\n' : 'mode: dry run — pass --apply to write\n')
const present = new Set((await sourceDb.listCollections({}, { nameOnly: true }).toArray()).map((c) => c.name))
const rows = []
// Identity mirror, derived from the website's users.
if (!present.has('users')) {
console.warn('! source has no `users` collection — skipping the identity mirror')
} else {
const users = await sourceDb.collection('users').find({}).toArray()
const appUsers = users.map(toAppUser)
const ids = appUsers.map((u) => u._id)
const already = await countExisting(targetDb.collection('appusers'), ids)
const written = APPLY ? await upsertAll(targetDb.collection('appusers'), appUsers) : 0
rows.push({ collection: 'appusers (derived from users)', source: users.length, already, written })
}
for (const name of COPY_COLLECTIONS) {
if (!present.has(name)) {
rows.push({ collection: name, source: 0, already: 0, written: 0, note: 'not in source' })
continue
}
const documents = await sourceDb.collection(name).find({}).toArray()
const ids = documents.map((d) => d._id)
const already = await countExisting(targetDb.collection(name), ids)
const written = APPLY ? await upsertAll(targetDb.collection(name), documents) : 0
rows.push({ collection: name, source: documents.length, already, written })
}
const pad = (s, n) => String(s).padEnd(n)
console.log(`${pad('collection', 32)}${pad('in source', 12)}${pad('already there', 15)}${APPLY ? 'written' : ''}`)
for (const r of rows) {
console.log(
pad(r.collection, 32) + pad(r.source, 12) + pad(r.already, 15) +
(APPLY ? String(r.written) : '') + (r.note ? ` (${r.note})` : ''),
)
}
if (!APPLY) console.log('\nNothing was written. Re-run with --apply once the numbers look right.')
} finally {
await source.close()
await target.close()
}
}
main().catch((error) => {
console.error('✗ migration failed:', error?.message || error)
process.exit(1)
})