9.4 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 14-postgresql-migration | 05 | execute | 2 |
|
|
true |
|
|
Purpose: Existing users need to migrate their data from SQLite to Postgres without data loss (DB-04). This is a standalone script run once during the upgrade. Output: scripts/migrate-sqlite-to-postgres.ts
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/14-postgresql-migration/14-CONTEXT.md @.planning/phases/14-postgresql-migration/14-RESEARCH.md @.planning/phases/14-postgresql-migration/14-01-SUMMARY.md@src/db/schema.ts
Tables in dependency order:
- categories, users, settings (no foreign keys to other app tables)
- items, threads, sessions, apiKeys, oauthClients (FK to categories/users)
- threadCandidates, setups, oauthCodes, oauthTokens (FK to threads/etc)
- setupItems (FK to setups + items)
// scripts/migrate-sqlite-to-postgres.ts
import { Database } from "bun:sqlite";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "../src/db/schema.ts";
Environment variables:
SQLITE_PATH-- path to SQLite database file (default:"gearbox.db")DATABASE_URL-- PostgreSQL connection string (required)
Structure:
- Open SQLite database read-only
- Connect to PostgreSQL via postgres.js + drizzle
- Migrate tables in dependency order (parents before children)
- Reset all serial sequences after migration
- Close both connections
- Print summary
Type conversion functions:
function unixToDate(unix: number | null): Date | null {
if (unix === null || unix === undefined) return null;
return new Date(unix * 1000); // Unix seconds to JS milliseconds
}
function intToBool(val: number | null): boolean {
return val === 1;
}
Migration order and transform functions for each table:
- categories --
id(serial),name,icon,createdAt(unixToDate) - users --
id(serial),username,passwordHash,createdAt(unixToDate) - settings --
key,value(no transforms needed, text PK) - items --
id(serial),name,weightGrams,priceCents,categoryId,notes,productUrl,imageFilename,imageSourceUrl,quantity,createdAt(unixToDate),updatedAt(unixToDate) - threads --
id(serial),name,status,resolvedCandidateId,categoryId,createdAt(unixToDate),updatedAt(unixToDate) - sessions --
id(text PK),userId,expiresAt(unixToDate) - apiKeys --
id(serial),name,keyHash,keyPrefix,createdAt(unixToDate) - oauthClients --
id(serial),clientId,clientName,redirectUris,createdAt(unixToDate) - threadCandidates --
id(serial), all fields,createdAt/updatedAt(unixToDate),sortOrder(keep as number) - setups --
id(serial),name,createdAt/updatedAt(unixToDate) - oauthCodes --
id(serial), all fields,expiresAt(unixToDate),used(intToBool) - oauthTokens --
id(serial), all fields,expiresAt/refreshExpiresAt/createdAt(unixToDate) - setupItems --
id(serial),setupId,itemId,classification
For each table, use this pattern:
async function migrateTable(tableName: string, pgTable: any, transform: (row: any) => any) {
const rows = sqlite.query(`SELECT * FROM ${tableName}`).all();
console.log(` ${tableName}: ${rows.length} rows`);
if (rows.length === 0) return;
for (const row of rows) {
await db.insert(pgTable).values(transform(row));
}
}
Sequence reset after all data is migrated:
async function resetSequences() {
const tablesWithSerial = [
"categories", "items", "threads", "thread_candidates",
"setups", "setup_items", "users", "api_keys",
"oauth_clients", "oauth_codes", "oauth_tokens"
];
for (const table of tablesWithSerial) {
await sql`SELECT setval(pg_get_serial_sequence('${sql.raw(table)}', 'id'), COALESCE((SELECT MAX(id) FROM ${sql.raw(table)}), 0))`;
}
}
Note: Use db.execute(sql\...`)from drizzle-orm for raw SQL, or usepg`...`from the postgres.js client directly. Thesql.raw()` helper is needed for dynamic table names.
Main function:
async function main() {
const sqlitePath = process.env.SQLITE_PATH || "gearbox.db";
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
console.error("ERROR: DATABASE_URL environment variable is required");
process.exit(1);
}
console.log(`Migrating from SQLite (${sqlitePath}) to PostgreSQL...`);
const sqlite = new Database(sqlitePath, { readonly: true });
const pg = postgres(databaseUrl);
const db = drizzle(pg, { schema });
// ... migrate all tables in order ...
// ... reset sequences ...
await pg.end();
sqlite.close();
console.log("Migration complete!");
}
main().catch((err) => {
console.error("Migration failed:", err);
process.exit(1);
});
Error handling per table: Wrap each table migration in try/catch, log which table failed and which row (by ID if available), then re-throw. This aids debugging partial migrations.
Add to package.json scripts:
"db:migrate-from-sqlite": "bun run scripts/migrate-sqlite-to-postgres.ts"
<success_criteria>
One-time migration script exists, handles all type conversions (timestamps, booleans), preserves IDs, resets sequences. Can be run with DATABASE_URL=... SQLITE_PATH=... bun run scripts/migrate-sqlite-to-postgres.ts. Lint passes.
</success_criteria>