lilith-platform.live/codebase/@features/api/scripts/migrate-from-quinn-admin.ts
2026-05-16 04:42:58 -07:00

217 lines
7.8 KiB
TypeScript

/**
* 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=<url> QUINN_DB_URL=<url> DRY_RUN=1 bun run scripts/migrate-from-quinn-admin.ts
* # Review orphan report, then:
* QUINN_ADMIN_DB_URL=<url> QUINN_DB_URL=<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<void> {
const aboutRows = await src<AdminAboutRow[]>`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<void> {
const linkRows = await src<AdminLinkValueRow[]>`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<void> {
const adminRows = await src<AdminGalleryRow[]>`
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<string> = 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<void> {
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<Array<{ count: string }>>`SELECT count(*)::text AS count FROM about`;
const dstLink = await dst<Array<{ count: string }>>`SELECT count(*)::text AS count FROM link_values`;
const dstGallery = await dst<Array<{ count: string }>>`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);
});