mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-01 14:16:05 +08:00
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>
162 lines
6.0 KiB
JavaScript
162 lines
6.0 KiB
JavaScript
#!/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)
|
|
})
|