lilith-platform.live/scripts/sqlite-to-pg-migrate.ts
2026-04-20 03:04:57 -07:00

190 lines
6.3 KiB
TypeScript

#!/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<string, readonly string[]> = {
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<string, unknown>;
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<void> {
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<void> {
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<void> {
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<number, string>(
(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<void> {
out(`Source: ${SQLITE_PATH}`);
out(`Target: ${DB_URL.replace(/:\/\/[^@]+@/, '://<creds>@')}\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();
});