mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-07-31 22:05:34 +08:00
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:
161
scripts/migrate-from-website-db.mjs
Normal file
161
scripts/migrate-from-website-db.mjs
Normal 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)
|
||||
})
|
||||
Reference in New Issue
Block a user