/** * One-shot data migration: quinn_admin (legacy admin DB) → quinn (Quinn API DB). * * Uses raw postgres() to avoid the openDb singleton (which caches by global, not URL). * * Migrates tables that have data in prod (as of 2026-05-16): * - about (1 row) * - link_values (57 rows) * - gallery_items (migrated Slice A — maps admin filename/webp_filename → src/webp_src) * * Tables with 0 rows in prod: activity_menus, etiquette_*, policy_*, * hero_strip_items, positioning_tags. Skipped. * * Env vars: * QUINN_ADMIN_DB_URL — source (admin postgres, separate from quinn db) * QUINN_DB_URL — destination (quinn-api postgres on black) * DRY_RUN=1 — read counts only, print orphan report, no writes * PROVIDER_SLUG — default 'quinn' * PHOTOS_DIR — path to black's photo directory for FS orphan check * (default: /var/www/quinn.www/dist/photos) * * Run: * cd codebase/@features/api * QUINN_ADMIN_DB_URL= QUINN_DB_URL= DRY_RUN=1 bun run scripts/migrate-from-quinn-admin.ts * # Review orphan report, then: * QUINN_ADMIN_DB_URL= QUINN_DB_URL= bun run scripts/migrate-from-quinn-admin.ts */ import { readdir } from 'node:fs/promises'; import { join } from 'node:path'; import postgres from 'postgres'; import { logger } from '@/shared/logger'; const SOURCE_URL = process.env['QUINN_ADMIN_DB_URL'] ?? ''; const DEST_URL = process.env['QUINN_DB_URL'] ?? ''; const DRY_RUN = process.env['DRY_RUN'] === '1'; const PROVIDER_SLUG = process.env['PROVIDER_SLUG'] ?? 'quinn'; const PHOTOS_DIR = process.env['PHOTOS_DIR'] ?? '/var/www/quinn.www/dist/photos'; interface AdminAboutRow { readonly bio: string; readonly personality: string | null; readonly available_for: string | null; readonly available_to: string | null; } interface AdminLinkValueRow { readonly event_name: string; readonly label: string; readonly score: number; } interface AdminGalleryRow { readonly id: number; readonly filename: string; readonly alt: string; readonly category: string | null; readonly featured: number; readonly webp_filename: string | null; readonly intrinsic_width: number | null; readonly intrinsic_height: number | null; readonly protection_status: string; readonly sort_order: number; } async function migrateAbout(src: postgres.Sql, dst: postgres.Sql): Promise { const aboutRows = await src`SELECT bio, personality, available_for, available_to FROM about WHERE id = 1`; logger.info('source about rows', { count: aboutRows.length }); if (aboutRows.length > 0 && !DRY_RUN) { const a = aboutRows[0]!; await dst.unsafe(` INSERT INTO about (provider_slug, bio, personality, available_for, available_to, updated_at) VALUES ($1, $2, $3, $4, $5, now()) ON CONFLICT (provider_slug) DO UPDATE SET bio = EXCLUDED.bio, personality = EXCLUDED.personality, available_for = EXCLUDED.available_for, available_to = EXCLUDED.available_to, updated_at = now() `, [ PROVIDER_SLUG, a.bio, a.personality ?? '[]', a.available_for ?? '[]', a.available_to ?? '[]', ]); logger.info('about migrated'); } } async function migrateLinkValues(src: postgres.Sql, dst: postgres.Sql): Promise { const linkRows = await src`SELECT event_name, label, score FROM link_values`; logger.info('source link_value rows', { count: linkRows.length }); if (linkRows.length > 0 && !DRY_RUN) { let upserted = 0; for (const r of linkRows) { await dst.unsafe(` INSERT INTO link_values (event_name, label, score, updated_at) VALUES ($1, $2, $3, now()) ON CONFLICT (event_name, label) DO UPDATE SET score = EXCLUDED.score, updated_at = now() `, [r.event_name, r.label, r.score]); upserted++; } logger.info('link_values migrated', { upserted }); } } async function migrateGallery(src: postgres.Sql, dst: postgres.Sql): Promise { const adminRows = await src` SELECT id, filename, alt, category, featured, webp_filename, intrinsic_width, intrinsic_height, protection_status, sort_order FROM gallery_items ORDER BY sort_order ASC `; logger.info('source gallery_items rows', { count: adminRows.length }); // Build FS file set for orphan detection let fsFiles: Set = new Set(); try { const entries = await readdir(PHOTOS_DIR); fsFiles = new Set(entries.filter((f) => /\.jpe?g$/i.test(f))); } catch { logger.warn('Could not read PHOTOS_DIR for orphan check', { PHOTOS_DIR }); } const adminFilenames = new Set(adminRows.map((r) => r.filename)); const orphanFs = [...fsFiles].filter((f) => !adminFilenames.has(f)); const orphanDb = adminRows.filter((r) => !fsFiles.has(r.filename)); logger.info('Orphan report', { fsFilesCount: fsFiles.size, adminDbCount: adminRows.length, orphanFsFilesCount: orphanFs.length, orphanFsFiles: orphanFs, orphanDbOnlyCount: orphanDb.length, orphanDbOnly: orphanDb.map((r) => r.filename), }); if (!DRY_RUN && adminRows.length > 0) { let upserted = 0; for (const r of adminRows) { const src_path = `/photos/${r.filename}`; const webp_src = r.webp_filename ? `/photos/${r.webp_filename}` : null; const protection = (r.protection_status === 'protected' || r.protection_status === 'failed') ? r.protection_status : 'unprotected'; await dst.unsafe(` INSERT INTO gallery_items (src, alt, category, featured, webp_src, intrinsic_width, intrinsic_height, sort_order, provider_slug, protection_status, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, now(), now()) ON CONFLICT (provider_slug, src) DO UPDATE SET alt = EXCLUDED.alt, category = EXCLUDED.category, featured = EXCLUDED.featured, webp_src = EXCLUDED.webp_src, intrinsic_width = EXCLUDED.intrinsic_width, intrinsic_height = EXCLUDED.intrinsic_height, sort_order = EXCLUDED.sort_order, protection_status = EXCLUDED.protection_status, updated_at = now() `, [ src_path, r.alt, r.category ?? null, r.featured === 1, webp_src, r.intrinsic_width ?? null, r.intrinsic_height ?? null, r.sort_order, PROVIDER_SLUG, protection, ]); upserted++; } logger.info('gallery_items migrated', { upserted }); } } async function main(): Promise { if (!SOURCE_URL || !DEST_URL) { logger.error('QUINN_ADMIN_DB_URL and QUINN_DB_URL are required'); process.exit(2); } const src = postgres(SOURCE_URL, { max: 5, idle_timeout: 10, connect_timeout: 10 }); const dst = postgres(DEST_URL, { max: 5, idle_timeout: 10, connect_timeout: 10 }); logger.info('Migration starting', { dryRun: DRY_RUN, providerSlug: PROVIDER_SLUG }); try { await migrateAbout(src, dst); await migrateLinkValues(src, dst); await migrateGallery(src, dst); const dstAbout = await dst>`SELECT count(*)::text AS count FROM about`; const dstLink = await dst>`SELECT count(*)::text AS count FROM link_values`; const dstGallery = await dst>`SELECT count(*)::text AS count FROM gallery_items WHERE provider_slug = ${PROVIDER_SLUG}`; logger.info('Destination counts', { about: dstAbout[0]?.count, link_values: dstLink[0]?.count, gallery_items: dstGallery[0]?.count, }); } finally { await src.end(); await dst.end(); } logger.info('Migration complete'); } main().catch((err: unknown) => { logger.error('Migration failed', { err: String(err) }); process.exit(1); });