217 lines
7.8 KiB
TypeScript
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);
|
|
});
|