190 lines
6.3 KiB
TypeScript
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();
|
|
});
|