infra(migrate-specific): 🧱 Introduce TypeScript migration script to extract, transform, and load data from quinn-admin to quinn-pg

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-05-05 15:16:53 -07:00
parent 5e4e26afe2
commit 4bf444c24b

View file

@ -0,0 +1,216 @@
#!/usr/bin/env bun
/**
* One-shot migration: quinn_admin Postgres quinn Postgres (the unified backend).
*
* Copies the singleton + rate data that lives only in quinn_admin into the
* shapes quinn.api expects:
*
* quinn_admin.identity
* quinn_admin.physical quinn.provider_profiles (provider_slug='quinn')
*
* quinn_admin.rate_sections quinn.rate_cards
* quinn_admin.rate_entries quinn.rate_entries (FK card_id new rate_cards.id)
*
* Idempotent:
* - provider_profiles: ON CONFLICT (provider_slug) DO NOTHING.
* If a row already exists for 'quinn' we leave it alone (re-run is safe but
* will not overwrite a populated profile).
* - rate_cards / rate_entries: skipped entirely if the target already has
* any rate_cards rows for provider_slug='quinn'.
*
* Prerequisites (run from this repo root):
*
* ssh -L 25435:127.0.0.1:6432 quinn-vps -fN
*
* Both DSNs default to that tunnel override with env vars if you want to
* point elsewhere.
*
* bun run scripts/quinn-admin-to-quinn-pg-migrate.ts
*
* Note: quinn_admin password is not embedded set ADMIN_DB_URL.
*/
import postgres from 'postgres';
const ADMIN_DB_URL =
process.env['ADMIN_DB_URL'] ??
(() => { throw new Error('ADMIN_DB_URL env var required (postgres://quinn_admin:...@127.0.0.1:25435/quinn_admin)'); })();
const QUINN_DB_URL =
process.env['QUINN_DB_URL'] ??
'postgres://quinn_api:5odj1WzCPk0BGtoVdHAIV7Z1veb2AzewM0k4sVqI@127.0.0.1:25435/quinn';
const PROVIDER_SLUG = process.env['PROVIDER_SLUG'] ?? 'quinn';
const adminSql = postgres(ADMIN_DB_URL, { max: 1, onnotice: () => { /* hush */ } });
const quinnSql = postgres(QUINN_DB_URL, { max: 1, onnotice: () => { /* hush */ } });
const out = (msg: string): void => { process.stdout.write(msg + '\n'); };
const err = (msg: string): void => { process.stderr.write(msg + '\n'); };
// ---------------------------------------------------------------------------
// identity + physical → provider_profiles
// ---------------------------------------------------------------------------
interface IdentityRow {
name: string; pronouns: string; gender: string; location: string;
incall_city: string | null; tagline: string;
secondary_locations: string; languages: string;
}
interface PhysicalRow {
age: string | null; height: string | null; body_type: string | null;
ethnicity: string | null; hair_color: string | null; hair_length: string | null;
eye_color: string | null; cup_size: string | null;
additional: string;
}
function safeJsonParse<T>(raw: string, fallback: T): T {
try { return JSON.parse(raw) as T; } catch { return fallback; }
}
async function migrateProviderProfile(): Promise<void> {
const [identity] = await adminSql<IdentityRow[]>`SELECT * FROM identity WHERE id = 1`;
const [physical] = await adminSql<PhysicalRow[]>`SELECT * FROM physical WHERE id = 1`;
if (!identity && !physical) {
out('SKIP provider_profile: no identity/physical rows in quinn_admin');
return;
}
const existing = await quinnSql<Array<{ provider_slug: string }>>`
SELECT provider_slug FROM provider_profiles WHERE provider_slug = ${PROVIDER_SLUG}
`;
if (existing.length > 0) {
out(`SKIP provider_profile: quinn already has provider_profiles row for '${PROVIDER_SLUG}'`);
return;
}
const identityJson = identity ? {
name: identity.name,
pronouns: identity.pronouns,
gender: identity.gender,
location: identity.location,
...(identity.incall_city ? { incallCity: identity.incall_city } : {}),
secondaryLocations: safeJsonParse<string[]>(identity.secondary_locations, []),
languages: safeJsonParse<string[]>(identity.languages, []),
tagline: identity.tagline,
} : {};
const physicalJson = physical ? {
age: physical.age ?? '',
height: physical.height ?? '',
bodyType: physical.body_type ?? '',
ethnicity: physical.ethnicity ?? '',
hairColor: physical.hair_color ?? '',
hairLength: physical.hair_length ?? '',
eyeColor: physical.eye_color ?? '',
cupSize: physical.cup_size ?? '',
additional: safeJsonParse<Record<string, string>>(physical.additional, {}),
} : {};
await quinnSql`
INSERT INTO provider_profiles (provider_slug, identity_json, physical_json)
VALUES (
${PROVIDER_SLUG},
${JSON.stringify(identityJson)},
${JSON.stringify(physicalJson)}
)
ON CONFLICT (provider_slug) DO NOTHING
`;
out(`INSERT provider_profiles: provider_slug='${PROVIDER_SLUG}' (identity=${!!identity}, physical=${!!physical})`);
}
// ---------------------------------------------------------------------------
// rate_sections + rate_entries → rate_cards + rate_entries
// ---------------------------------------------------------------------------
interface AdminRateSection {
id: number; section_type: string; title: string;
description: string | null; sort_order: number;
}
interface AdminRateEntry {
id: number; section_id: number; service: string;
duration: string | null; price: number; price_max: number | null;
description: string | null; notes: string | null; sort_order: number;
}
const QUINN_KINDS = new Set(['incall', 'outcall', 'addons', 'travel', 'touring', 'online']);
async function migrateRates(): Promise<void> {
const [{ c }] = await quinnSql<[{ c: number }]>`
SELECT count(*)::int AS c FROM rate_cards WHERE provider_slug = ${PROVIDER_SLUG}
`;
if (c > 0) { out(`SKIP rate_cards: quinn already has ${c} rows for '${PROVIDER_SLUG}'`); return; }
const sections = await adminSql<AdminRateSection[]>`SELECT * FROM rate_sections ORDER BY id ASC`;
if (sections.length === 0) { out('SKIP rate_sections: empty in quinn_admin'); return; }
const entries = await adminSql<AdminRateEntry[]>`SELECT * FROM rate_entries ORDER BY id ASC`;
const entriesBySection = new Map<number, AdminRateEntry[]>();
for (const e of entries) {
const list = entriesBySection.get(e.section_id) ?? [];
list.push(e);
entriesBySection.set(e.section_id, list);
}
let cardsInserted = 0;
let entriesInserted = 0;
for (const sec of sections) {
const kind = QUINN_KINDS.has(sec.section_type) ? sec.section_type : null;
if (!kind) {
out(`WARN rate_section id=${sec.id} section_type='${sec.section_type}' is not a valid quinn rate_card kind — skipping`);
continue;
}
const [card] = await quinnSql<Array<{ id: number }>>`
INSERT INTO rate_cards (kind, title, description, sort_order, provider_slug)
VALUES (${kind}, ${sec.title}, ${sec.description}, ${sec.sort_order}, ${PROVIDER_SLUG})
RETURNING id
`;
if (!card) continue;
cardsInserted += 1;
const sectionEntries = entriesBySection.get(sec.id) ?? [];
for (const e of sectionEntries) {
await quinnSql`
INSERT INTO rate_entries (
card_id, service, duration, price, price_max,
description, notes, sort_order
) VALUES (
${card.id}, ${e.service}, ${e.duration}, ${e.price}, ${e.price_max},
${e.description}, ${e.notes}, ${e.sort_order}
)
`;
entriesInserted += 1;
}
}
out(`INSERT rate_cards: ${cardsInserted} | rate_entries: ${entriesInserted}`);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main(): Promise<void> {
const redact = (u: string): string => u.replace(/:\/\/[^@]+@/, '://<creds>@');
out(`Source: ${redact(ADMIN_DB_URL)}`);
out(`Target: ${redact(QUINN_DB_URL)}`);
out(`Provider slug: ${PROVIDER_SLUG}\n`);
await migrateProviderProfile();
await migrateRates();
out('\nDone.');
}
main().catch((e: unknown) => {
err(String(e));
process.exit(1);
}).finally(async () => {
await adminSql.end();
await quinnSql.end();
});