// Go-live data carry-over: copies every row from the local SQLite dev database into the // target (cloud) Postgres database, preserving values and ids as-is — this is a one-time // full copy, not the "born in Postgres" empty-start path in docs/DEPLOY-HQ.md. // Column types are a straight 1:1 map on this schema (money is bigint paise, flags stay // integer, JSON payloads stay text — see migrations-pg.ts header), so no value conversion // is needed; every column is copied through unchanged. // Refuses to run if the target already has staff_user rows, so a re-run (or pointing at // the wrong DATABASE_URL) can't silently duplicate or clobber data. // stg_client/stg_invoice are APEX-import scratch tables, not real data — skipped. // // Run from repo root, source = local ./data/hq.db (or HQ_DATA_DIR), target = DATABASE_URL: // DATABASE_URL=postgres://postgres:@:5432/hq npx tsx apps/hq/scripts/migrate-sqlite-to-pg.ts import { openDb, type SqliteDb } from '../src/db' import { openPgDb, type PgDb } from '../src/db-pg' const SKIP_TABLES = new Set(['stg_client', 'stg_invoice', 'schema_migrations']) async function main(): Promise { const pgUrl = process.env['DATABASE_URL'] if (pgUrl === undefined || pgUrl === '') { throw new Error('Set DATABASE_URL to the target (cloud) Postgres connection string.') } const sqlite = openDb(process.env['HQ_DATA_DIR']) as SqliteDb const pg = (await openPgDb(pgUrl)) as PgDb // schema is created by migrations here; seedIfEmpty is NOT called const existingStaff = (await pg.get<{ n: number }>(`SELECT COUNT(*) AS n FROM staff_user`))!.n if (existingStaff > 0) { throw new Error( `Target already has ${existingStaff} staff_user row(s) — refusing to import over existing data. ` + `Wrong DATABASE_URL, or this has already been migrated.`, ) } const tables = sqlite.raw .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`) .all() as { name: string }[] const client = await pg.pool.connect() try { await client.query('BEGIN') // Load in table-discovery order and ignore FK/trigger checks — safe here because the // whole load is one transaction into a database we just confirmed is empty. await client.query('SET LOCAL session_replication_role = replica') for (const { name } of tables) { if (SKIP_TABLES.has(name)) { console.log(`skip ${name}`) continue } const rows = sqlite.raw.prepare(`SELECT * FROM ${name}`).all() as Record[] if (rows.length === 0) { console.log(`${name}: 0 rows`) continue } const cols = Object.keys(rows[0]!) const placeholders = cols.map((_, i) => `$${i + 1}`).join(',') const insertSql = `INSERT INTO ${name} (${cols.join(',')}) VALUES (${placeholders})` for (const row of rows) { await client.query(insertSql, cols.map((c) => row[c])) } console.log(`${name}: ${rows.length} row(s) migrated`) } await client.query('COMMIT') } catch (err) { await client.query('ROLLBACK') throw err } finally { client.release() } console.log('Migration complete.') } main().catch((e: unknown) => { console.error(e) process.exit(1) })