mirror of
https://github.com/linux-odyssey/linux-odyssey.git
synced 2026-11-06 03:20:13 +08:00
404 lines
12 KiB
TypeScript
404 lines
12 KiB
TypeScript
/**
|
|
* One-shot migration of the legacy Mongo data into Postgres.
|
|
*
|
|
* MONGO_URL=... DATABASE_URL=... bun run scripts/migrate-from-mongo.ts [--dry-run] [--force]
|
|
*
|
|
* - Stop the server before running; this is a snapshot copy, not a sync.
|
|
* - Everything runs in ONE Postgres transaction, so a failure leaves Postgres untouched
|
|
* and the script can simply be re-run.
|
|
* - Refuses to run if any target table is non-empty, unless --force is given
|
|
* (--force does not delete anything; it only skips the check).
|
|
* - New ids are assigned here (sequential), so relations can be rewired from ObjectIds.
|
|
* The id sequences are advanced afterwards.
|
|
* - Rows that cannot be migrated (orphans, unknown providers) are written to
|
|
* migration-skipped.json and counted in the summary.
|
|
*/
|
|
import 'dotenv/config'
|
|
import { writeFileSync } from 'node:fs'
|
|
import { Temporal } from 'temporal-polyfill'
|
|
import {
|
|
connectDB,
|
|
User,
|
|
UserProfile,
|
|
Session,
|
|
Command,
|
|
LoginAttempt,
|
|
} from '@odyssey/models'
|
|
import { db } from '../src/prisma/db'
|
|
|
|
const DRY_RUN = process.argv.includes('--dry-run')
|
|
const FORCE = process.argv.includes('--force')
|
|
const BATCH_SIZE = 1000
|
|
|
|
const LOGIN_METHODS = ['password', 'google', 'github'] as const
|
|
type LoginMethod = (typeof LOGIN_METHODS)[number]
|
|
const isLoginMethod = (v: string): v is LoginMethod =>
|
|
(LOGIN_METHODS as readonly string[]).includes(v)
|
|
|
|
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]
|
|
|
|
const skipped: {
|
|
table: string
|
|
reason: string
|
|
id?: string
|
|
detail?: unknown
|
|
}[] = []
|
|
const stats: Record<
|
|
string,
|
|
{ source: number; inserted: number; skipped: number }
|
|
> = {}
|
|
|
|
function track(table: string) {
|
|
return (stats[table] ??= { source: 0, inserted: 0, skipped: 0 })
|
|
}
|
|
|
|
function skip(table: string, reason: string, id?: unknown, detail?: unknown) {
|
|
track(table).skipped++
|
|
skipped.push({
|
|
table,
|
|
reason,
|
|
id: id === undefined ? undefined : String(id),
|
|
detail,
|
|
})
|
|
}
|
|
|
|
const instant = (d: Date) => Temporal.Instant.fromEpochMilliseconds(d.getTime())
|
|
const instantOrNull = (d?: Date | null) => (d ? instant(d) : null)
|
|
|
|
// Postgres text cannot hold NUL bytes, which some captured terminal output contains.
|
|
let nulStripped = 0
|
|
function text(v: string): string
|
|
function text(v: string | null | undefined): string | null
|
|
function text(v: string | null | undefined) {
|
|
if (v == null) return null
|
|
if (!v.includes('\0')) return v
|
|
nulStripped++
|
|
return v.replaceAll('\0', '')
|
|
}
|
|
|
|
// Mongoose subdocuments carry _id; the Postgres graph column stores the bare tree.
|
|
interface GraphNode {
|
|
path: string
|
|
type: string
|
|
discovered: boolean
|
|
children?: GraphNode[]
|
|
}
|
|
function cleanGraph(node: any): GraphNode {
|
|
const out: GraphNode = {
|
|
path: node?.path ?? '/',
|
|
// legacy sessions used 'folder' for directories
|
|
type: node?.type === 'file' ? 'file' : 'directory',
|
|
discovered: node?.discovered ?? false,
|
|
}
|
|
if (node?.children?.length) out.children = node.children.map(cleanGraph)
|
|
return out
|
|
}
|
|
|
|
// A lean Mongoose Map comes back as a plain object, a hydrated one as a Map.
|
|
const entries = <T>(m: Map<string, T> | Record<string, T> | undefined) =>
|
|
m instanceof Map ? [...m.entries()] : Object.entries(m ?? {})
|
|
|
|
async function insert(tx: Tx, table: string, plan: unknown, count: number) {
|
|
if (DRY_RUN || count === 0) {
|
|
track(table).inserted += count
|
|
return
|
|
}
|
|
const { affectedRows } = await tx.execute(plan as never)
|
|
track(table).inserted += Number(affectedRows)
|
|
}
|
|
|
|
async function* batches<T>(cursor: AsyncIterable<T>, size: number) {
|
|
let buf: T[] = []
|
|
for await (const item of cursor) {
|
|
buf.push(item)
|
|
if (buf.length >= size) {
|
|
yield buf
|
|
buf = []
|
|
}
|
|
}
|
|
if (buf.length) yield buf
|
|
}
|
|
|
|
async function migrate(tx: Tx) {
|
|
const t = tx.sql.public
|
|
const userIds = new Map<string, bigint>()
|
|
const userIdsByUsername = new Map<string, bigint>()
|
|
const sessionIds = new Map<string, bigint>()
|
|
const seenEmails = new Set<string>()
|
|
const seenOAuth = new Set<string>()
|
|
let nextUserId = 1n
|
|
let nextSessionId = 1n
|
|
let nextCommandId = 1n
|
|
let nextAttemptId = 1n
|
|
|
|
// --- User + UserLoginMethod
|
|
for await (const users of batches(
|
|
User.find().sort({ _id: 1 }).lean().cursor(),
|
|
BATCH_SIZE
|
|
)) {
|
|
const userRows: any[] = []
|
|
const methodRows: any[] = []
|
|
for (const u of users) {
|
|
track('User').source++
|
|
const email = u.email.trim().toLowerCase()
|
|
if (seenEmails.has(email)) {
|
|
throw new Error(
|
|
`Duplicate email after lowercasing: ${email} (user ${u._id}). Resolve it in Mongo first.`
|
|
)
|
|
}
|
|
seenEmails.add(email)
|
|
|
|
const id = nextUserId++
|
|
userIds.set(String(u._id), id)
|
|
userIdsByUsername.set(u.username, id)
|
|
userRows.push({
|
|
id,
|
|
email,
|
|
username: u.username,
|
|
name: null,
|
|
createdAt: instant(u.createdAt),
|
|
updatedAt: instant(u.updatedAt),
|
|
isGuest: u.isGuest ?? false,
|
|
})
|
|
|
|
if (u.hashedPassword) {
|
|
methodRows.push({
|
|
userId: id,
|
|
type: 'password',
|
|
hashedPassword: u.hashedPassword,
|
|
oauthId: null,
|
|
displayName: null,
|
|
createdAt: instant(u.createdAt),
|
|
updatedAt: instant(u.updatedAt),
|
|
})
|
|
}
|
|
for (const [key, login] of entries(u.socialLogins)) {
|
|
const provider = (login.provider ?? key).toLowerCase()
|
|
if (!isLoginMethod(provider) || provider === 'password') {
|
|
skip('UserLoginMethod', 'unknown provider', u._id, { provider })
|
|
continue
|
|
}
|
|
const dedupe = `${provider}:${login.id}`
|
|
if (seenOAuth.has(dedupe)) {
|
|
skip('UserLoginMethod', 'duplicate (type, oauthId)', u._id, {
|
|
provider,
|
|
oauthId: login.id,
|
|
})
|
|
continue
|
|
}
|
|
seenOAuth.add(dedupe)
|
|
methodRows.push({
|
|
userId: id,
|
|
type: provider,
|
|
hashedPassword: null,
|
|
oauthId: login.id,
|
|
displayName: login.displayName ?? null,
|
|
createdAt: instant(u.createdAt),
|
|
updatedAt: instant(u.updatedAt),
|
|
})
|
|
}
|
|
}
|
|
await insert(tx, 'User', t.User.insert(userRows).build(), userRows.length)
|
|
track('UserLoginMethod').source += methodRows.length
|
|
await insert(
|
|
tx,
|
|
'UserLoginMethod',
|
|
t.UserLoginMethod.insert(methodRows).build(),
|
|
methodRows.length
|
|
)
|
|
}
|
|
|
|
// --- UserProgress (UserProfile.progress map, flattened)
|
|
for await (const profiles of batches(
|
|
UserProfile.find().lean().cursor(),
|
|
BATCH_SIZE
|
|
)) {
|
|
const rows: any[] = []
|
|
for (const p of profiles) {
|
|
const userId = userIds.get(String(p.user))
|
|
const items = entries(p.progress)
|
|
track('UserProgress').source += items.length
|
|
if (userId === undefined) {
|
|
for (const [quest] of items)
|
|
skip('UserProgress', 'user not found', p.user, { quest })
|
|
continue
|
|
}
|
|
for (const [key, prog] of items) {
|
|
rows.push({
|
|
userId,
|
|
quest: prog.quest ?? key,
|
|
startedAt: instant(prog.startedAt),
|
|
// `completed` is no longer stored: finishedAt IS NOT NULL means completed.
|
|
finishedAt: prog.completed
|
|
? instantOrNull(prog.finishedAt ?? (p as any).updatedAt)
|
|
: null,
|
|
})
|
|
}
|
|
}
|
|
await insert(
|
|
tx,
|
|
'UserProgress',
|
|
t.UserProgress.insert(rows).build(),
|
|
rows.length
|
|
)
|
|
}
|
|
|
|
// --- Session
|
|
for await (const sessions of batches(
|
|
Session.find().sort({ _id: 1 }).lean().cursor(),
|
|
BATCH_SIZE
|
|
)) {
|
|
const rows: any[] = []
|
|
for (const s of sessions) {
|
|
track('Session').source++
|
|
const userId = userIds.get(String(s.user))
|
|
if (userId === undefined) {
|
|
skip('Session', 'user not found', s._id, { user: String(s.user) })
|
|
continue
|
|
}
|
|
const id = nextSessionId++
|
|
sessionIds.set(String(s._id), id)
|
|
rows.push({
|
|
id,
|
|
userId,
|
|
quest: s.quest,
|
|
// sessions created before localization have no locale; they were zh-TW
|
|
locale: s.locale ?? 'zh-TW',
|
|
containerId: s.containerId ?? null,
|
|
containerName: s.containerName ?? null,
|
|
status: s.status,
|
|
createdAt: instant(s.createdAt),
|
|
lastActivityAt: instant(s.lastActivityAt ?? s.updatedAt),
|
|
finishedAt: instantOrNull(s.finishedAt),
|
|
completedEvents: s.completedEvents ?? [],
|
|
graph: cleanGraph(s.graph),
|
|
})
|
|
}
|
|
await insert(tx, 'Session', t.Session.insert(rows).build(), rows.length)
|
|
}
|
|
|
|
// --- Command (largest collection)
|
|
for await (const commands of batches(
|
|
Command.find().sort({ _id: 1 }).lean().cursor(),
|
|
BATCH_SIZE
|
|
)) {
|
|
const rows: any[] = []
|
|
for (const c of commands) {
|
|
track('Command').source++
|
|
const sessionId = sessionIds.get(String(c.session))
|
|
if (sessionId === undefined) {
|
|
skip('Command', 'session not found', c._id, {
|
|
session: String(c.session),
|
|
})
|
|
continue
|
|
}
|
|
rows.push({
|
|
id: nextCommandId++,
|
|
sessionId,
|
|
timestamp: instant(c.createdAt),
|
|
pwd: text(c.pwd),
|
|
command: text(c.command),
|
|
output: text(c.output),
|
|
error: text(c.error),
|
|
stage: text(c.stage),
|
|
})
|
|
}
|
|
await insert(tx, 'Command', t.Command.insert(rows).build(), rows.length)
|
|
}
|
|
|
|
// --- LoginAttempt (userId stays null when the username no longer exists)
|
|
for await (const attempts of batches(
|
|
LoginAttempt.find().sort({ _id: 1 }).lean().cursor(),
|
|
BATCH_SIZE
|
|
)) {
|
|
const rows = attempts.map((a) => {
|
|
track('LoginAttempt').source++
|
|
return {
|
|
id: nextAttemptId++,
|
|
username: a.username,
|
|
userId: userIdsByUsername.get(a.username) ?? null,
|
|
ip: a.ip,
|
|
loginMethod: 'password' as const, // legacy attempts were password-only
|
|
userAgent: a.userAgent,
|
|
time: instant(a.time ?? (a as any).createdAt),
|
|
success: a.success,
|
|
message: a.message ?? null,
|
|
}
|
|
})
|
|
await insert(
|
|
tx,
|
|
'LoginAttempt',
|
|
t.LoginAttempt.insert(rows).build(),
|
|
rows.length
|
|
)
|
|
}
|
|
|
|
// --- Advance the autoincrement sequences past the ids we assigned explicitly.
|
|
// Identifiers can't be bind parameters, so each statement is spelled out.
|
|
if (!DRY_RUN) {
|
|
const { raw } = db
|
|
const plans = [
|
|
raw.sql`SELECT setval(pg_get_serial_sequence('"User"', 'id'), COALESCE((SELECT MAX(id) FROM "User"), 0) + 1, false)`,
|
|
raw.sql`SELECT setval(pg_get_serial_sequence('"Session"', 'id'), COALESCE((SELECT MAX(id) FROM "Session"), 0) + 1, false)`,
|
|
raw.sql`SELECT setval(pg_get_serial_sequence('"Command"', 'id'), COALESCE((SELECT MAX(id) FROM "Command"), 0) + 1, false)`,
|
|
raw.sql`SELECT setval(pg_get_serial_sequence('"LoginAttempt"', 'id'), COALESCE((SELECT MAX(id) FROM "LoginAttempt"), 0) + 1, false)`,
|
|
]
|
|
for (const plan of plans)
|
|
await tx.execute(plan.affectedCount().build() as never)
|
|
}
|
|
}
|
|
|
|
async function assertTargetEmpty() {
|
|
const { orm } = db
|
|
const found = await Promise.all([
|
|
orm.public.User.first(),
|
|
orm.public.UserLoginMethod.first(),
|
|
orm.public.UserProgress.first(),
|
|
orm.public.Session.first(),
|
|
orm.public.Command.first(),
|
|
orm.public.LoginAttempt.first(),
|
|
])
|
|
if (found.some(Boolean)) {
|
|
throw new Error(
|
|
'Target tables are not empty. Truncate them or pass --force.'
|
|
)
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const mongoUrl = process.env['MONGO_URL']
|
|
if (!mongoUrl) throw new Error('MONGO_URL is not set')
|
|
const mongo = await connectDB(mongoUrl)
|
|
console.log(
|
|
DRY_RUN ? 'DRY RUN: nothing will be written' : 'Migrating Mongo -> Postgres'
|
|
)
|
|
|
|
try {
|
|
if (!FORCE && !DRY_RUN) await assertTargetEmpty()
|
|
await db.transaction((tx) => migrate(tx as Tx))
|
|
} finally {
|
|
await mongo.close()
|
|
await db.close()
|
|
}
|
|
|
|
console.table(stats)
|
|
if (nulStripped)
|
|
console.log(`Stripped NUL bytes from ${nulStripped} command text fields`)
|
|
writeFileSync('migration-skipped.json', JSON.stringify(skipped, null, 2))
|
|
console.log(
|
|
`Skipped rows written to migration-skipped.json (${skipped.length})`
|
|
)
|
|
for (const [table, s] of Object.entries(stats)) {
|
|
if (!DRY_RUN && s.source !== s.inserted + s.skipped) {
|
|
throw new Error(
|
|
`Count mismatch for ${table}: ${s.source} != ${s.inserted} + ${s.skipped}`
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err)
|
|
process.exit(1)
|
|
})
|