112 lines
3.8 KiB
TypeScript
112 lines
3.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-12):
|
|
* - about (1 row)
|
|
* - link_values (57 rows)
|
|
*
|
|
* Tables with 0 rows in prod: activity_menus, etiquette_*, policy_*,
|
|
* hero_strip_items, positioning_tags. Skipped.
|
|
*
|
|
* Env vars:
|
|
* QUINN_ADMIN_DB_URL — source
|
|
* QUINN_DB_URL — destination
|
|
* DRY_RUN=1 — read counts only
|
|
* PROVIDER_SLUG — default 'quinn'
|
|
*/
|
|
|
|
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';
|
|
|
|
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;
|
|
}
|
|
|
|
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 {
|
|
// --- about (1 row in prod) ---
|
|
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');
|
|
}
|
|
|
|
// --- link_values (57 rows in prod) ---
|
|
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 });
|
|
}
|
|
|
|
// --- verify ---
|
|
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`;
|
|
logger.info('Destination counts', {
|
|
about: dstAbout[0]?.count,
|
|
link_values: dstLink[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);
|
|
});
|