#!/usr/bin/env bun /** * One-shot migration: quinn-my.db (SQLite) → prod Postgres (quinn database) * * Prerequisites: * ssh -L 25436:127.0.0.1:6432 quinn-vps -fN * * Run from codebase/@features/my/backend-api/ (postgres package lives there): * cd codebase/@features/my/backend-api * bun ../../../../scripts/sqlite-to-pg-migrate.ts * * Or override env vars: * SQLITE_PATH=/abs/path/quinn-my.db MIGRATE_DB_URL=postgres://... bun ... */ import { Database } from 'bun:sqlite'; import postgres from 'postgres'; const SQLITE_PATH = process.env.SQLITE_PATH ?? new URL('../codebase/@features/my/backend-api/data/quinn-my.db', import.meta.url).pathname; const DB_URL = process.env.MIGRATE_DB_URL ?? 'postgres://quinn_my:plxLEliUesG73vnvwAFTefNlEWjSF2QRz1YCk6D7@127.0.0.1:25436/quinn'; const sqlite = new Database(SQLITE_PATH, { readonly: true }); const sql = postgres(DB_URL, { max: 1 }); const out = (msg: string): void => { process.stdout.write(msg + '\n'); }; const err = (msg: string): void => { process.stderr.write(msg + '\n'); }; // --------------------------------------------------------------------------- // Straight-copy tables: SQLite columns are a subset of prod columns. // All prod extra NOT NULL columns have safe defaults. // --------------------------------------------------------------------------- const BOOL_COLS: Record = { tasks: ['daily', 'completed'], calendar_sources: ['sync_enabled'], platforms: ['account', 'content', 'photos', 'verification_submitted', 'verified'], photo_css_traps: ['inverted'], }; // FK dependency order: parents before children const STRAIGHT_TABLES = [ 'admin_auth', 'projects', 'calendar_sources', 'platforms', 'platform_ad_copies', 'clients', 'income_meta', 'income_sessions', 'purchases', 'subscriptions', 'pending_items', 'pending_income', 'inspiration', 'context_sections', 'photo_css_traps', 'vigil_history', 'tasks', 'credentials', ] as const; // city_visits + flights: skipped — data already in tour_stops/tour_legs (newer source) // calendar_events: migrated via custom mapper below (schema renamed columns) type Row = Record; function coerce(table: string, rows: Row[]): Row[] { const bools = BOOL_COLS[table]; return rows.map(row => { const r = { ...row }; if (bools) { for (const col of bools) { if (col in r && r[col] !== null) r[col] = Boolean(r[col]); } } // tasks.priority: SQLite had 'medium'; prod allows only normal|high|urgent if (table === 'tasks' && r['priority'] === 'medium') r['priority'] = 'normal'; return r; }); } async function resetSequence(table: string): Promise { try { await sql.unsafe( `SELECT setval( pg_get_serial_sequence($1, 'id'), COALESCE((SELECT MAX(id) FROM ${table}), 0) + 1, false )`, [table], ); } catch { // Table has no serial 'id' column (e.g. platforms uses TEXT name PK, tasks uses TEXT id) } } async function migrateStraight(table: string): Promise { const rows = sqlite.prepare(`SELECT * FROM ${table}`).all() as Row[]; if (rows.length === 0) { out(`SKIP ${table}: empty in SQLite`); return; } const [{ c }] = await sql<[{ c: number }]>`SELECT count(*)::int as c FROM ${sql(table)}`; if (c > 0) { out(`SKIP ${table}: prod already has ${c} rows`); return; } await sql`INSERT INTO ${sql(table)} ${sql(coerce(table, rows))} ON CONFLICT DO NOTHING`; out(`INSERT ${table}: ${rows.length} rows`); await resetSequence(table); } // --------------------------------------------------------------------------- // Custom mapper: calendar_events // Prod schema renamed: uid→external_id, summary→title, dtstart→start_at, // dtend→end_at, description→notes. calendar_name derived from source. // --------------------------------------------------------------------------- function toTimestamp(date: unknown): string | null { if (!date) return null; const s = String(date).trim(); return s.includes('T') || s.includes(' ') ? s : `${s}T00:00:00`; } async function migrateCalendarEvents(): Promise { const [{ c }] = await sql<[{ c: number }]>`SELECT count(*)::int as c FROM calendar_events`; if (c > 0) { out(`SKIP calendar_events: prod already has ${c} rows`); return; } type SourceRow = { id: number; display_name: string }; const sourceNames = new Map( (sqlite.prepare('SELECT id, display_name FROM calendar_sources').all() as SourceRow[]) .map(r => [r.id, r.display_name]), ); type EventRow = { id: number; source_id: number | null; uid: string | null; summary: string | null; description: string | null; location: string | null; dtstart: string | null; dtend: string | null; all_day: number; created_at: string; updated_at: string; project_id: number | null; client_id: number | null; }; const rows = sqlite.prepare('SELECT * FROM calendar_events').all() as EventRow[]; const mapped: Row[] = rows.map(r => ({ id: r.id, external_id: r.uid ?? null, title: r.summary ?? '', start_at: toTimestamp(r.dtstart), end_at: toTimestamp(r.dtend) ?? toTimestamp(r.dtstart), all_day: Boolean(r.all_day), location: r.location ?? null, notes: r.description ?? null, calendar_name: r.source_id != null ? (sourceNames.get(r.source_id) ?? null) : null, provider_slug: 'quinn', synced_at: r.updated_at, created_at: r.created_at, updated_at: r.updated_at, project_id: r.project_id ?? null, client_id: r.client_id ?? null, })); await sql`INSERT INTO calendar_events ${sql(mapped)} ON CONFLICT DO NOTHING`; out(`INSERT calendar_events: ${mapped.length} rows`); await resetSequence('calendar_events'); } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- async function main(): Promise { out(`Source: ${SQLITE_PATH}`); out(`Target: ${DB_URL.replace(/:\/\/[^@]+@/, '://@')}\n`); for (const table of STRAIGHT_TABLES) { await migrateStraight(table); } await migrateCalendarEvents(); out('\nDone.'); } main().catch(e => { err(String(e)); process.exit(1); }).finally(() => { sqlite.close(); return sql.end(); });