#!/usr/bin/env tsx /** * Migration Script: SQLite → Supabase * * This script migrates all data from the local SQLite database to Supabase. * Run with: npx tsx scripts/migrate-to-supabase.ts */ import { createClient } from '@supabase/supabase-js'; import Database from 'better-sqlite3'; import { join } from 'path'; import { config } from 'dotenv'; // Load environment variables config({ path: '.env.local' }); // Validate environment variables const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL; const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY; if (!SUPABASE_URL || !SUPABASE_SERVICE_KEY) { console.error('āŒ Missing environment variables!'); console.error('Make sure you have created .env.local with:'); console.error(' - NEXT_PUBLIC_SUPABASE_URL'); console.error(' - SUPABASE_SERVICE_ROLE_KEY'); process.exit(1); } // Initialize clients const sqliteDb = new Database(join(process.cwd(), 'data', 'tasks.db')); const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY, { auth: { autoRefreshToken: false, persistSession: false } }); // Helper to convert SQLite ID to UUID (deterministic) function generateUUIDFromString(str: string): string { // Create a deterministic UUID v5-like string from the input // This ensures the same SQLite ID always maps to the same UUID const hash = str.split('').reduce((acc, char) => { return ((acc << 5) - acc) + char.charCodeAt(0) | 0; }, 0); const hex = Math.abs(hash).toString(16).padStart(32, '0'); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; } // Track ID mappings const userIdMap = new Map(); const projectIdMap = new Map(); const sprintIdMap = new Map(); async function migrateUsers() { console.log('šŸ“¦ Migrating users...'); const users = sqliteDb.prepare('SELECT * FROM users').all() as Array<{ id: string; name: string; email: string; avatarUrl: string | null; passwordHash: string; createdAt: string; }>; let migrated = 0; let skipped = 0; for (const user of users) { const uuid = generateUUIDFromString(user.id); userIdMap.set(user.id, uuid); const { error } = await supabase .from('users') .upsert({ id: uuid, legacy_id: user.id, name: user.name, email: user.email.toLowerCase().trim(), avatar_url: user.avatarUrl, password_hash: user.passwordHash, created_at: user.createdAt, }, { onConflict: 'email' }); if (error) { console.error(` āŒ Failed to migrate user ${user.email}:`, error.message); } else { migrated++; console.log(` āœ“ ${user.email}`); } } console.log(` āœ… Migrated ${migrated} users (${skipped} skipped)\n`); return migrated; } async function migrateSessions() { console.log('šŸ“¦ Migrating sessions...'); const sessions = sqliteDb.prepare('SELECT * FROM sessions').all() as Array<{ id: string; userId: string; tokenHash: string; createdAt: string; expiresAt: string; }>; let migrated = 0; for (const session of sessions) { const userUuid = userIdMap.get(session.userId); if (!userUuid) { console.log(` āš ļø Skipping session for unknown user: ${session.userId}`); continue; } const { error } = await supabase .from('sessions') .upsert({ id: generateUUIDFromString(session.id), user_id: userUuid, token_hash: session.tokenHash, created_at: session.createdAt, expires_at: session.expiresAt, }, { onConflict: 'token_hash' }); if (error) { console.error(` āŒ Failed to migrate session:`, error.message); } else { migrated++; } } console.log(` āœ… Migrated ${migrated} sessions\n`); return migrated; } async function migratePasswordResetTokens() { console.log('šŸ“¦ Migrating password reset tokens...'); const tokens = sqliteDb.prepare('SELECT * FROM password_reset_tokens').all() as Array<{ id: string; userId: string; tokenHash: string; expiresAt: string; createdAt: string; used: number; }>; let migrated = 0; for (const token of tokens) { const userUuid = userIdMap.get(token.userId); if (!userUuid) { console.log(` āš ļø Skipping token for unknown user: ${token.userId}`); continue; } const { error } = await supabase .from('password_reset_tokens') .upsert({ id: generateUUIDFromString(token.id), user_id: userUuid, token_hash: token.tokenHash, expires_at: token.expiresAt, created_at: token.createdAt, used: token.used === 1, }, { onConflict: 'token_hash' }); if (error) { console.error(` āŒ Failed to migrate token:`, error.message); } else { migrated++; } } console.log(` āœ… Migrated ${migrated} password reset tokens\n`); return migrated; } async function migrateProjects() { console.log('šŸ“¦ Migrating projects...'); const projects = sqliteDb.prepare('SELECT * FROM projects').all() as Array<{ id: string; name: string; description: string | null; color: string; createdAt: string; }>; let migrated = 0; for (const project of projects) { const uuid = generateUUIDFromString(project.id); projectIdMap.set(project.id, uuid); const { error } = await supabase .from('projects') .upsert({ id: uuid, legacy_id: project.id, name: project.name, description: project.description, color: project.color, created_at: project.createdAt, }, { onConflict: 'legacy_id' }); if (error) { console.error(` āŒ Failed to migrate project ${project.name}:`, error.message); } else { migrated++; console.log(` āœ“ ${project.name}`); } } console.log(` āœ… Migrated ${migrated} projects\n`); return migrated; } async function migrateSprints() { console.log('šŸ“¦ Migrating sprints...'); const sprints = sqliteDb.prepare('SELECT * FROM sprints').all() as Array<{ id: string; name: string; goal: string | null; startDate: string; endDate: string; status: string; projectId: string; createdAt: string; }>; let migrated = 0; for (const sprint of sprints) { const uuid = generateUUIDFromString(sprint.id); sprintIdMap.set(sprint.id, uuid); const projectUuid = projectIdMap.get(sprint.projectId); if (!projectUuid) { console.log(` āš ļø Skipping sprint ${sprint.name} - unknown project: ${sprint.projectId}`); continue; } const { error } = await supabase .from('sprints') .upsert({ id: uuid, legacy_id: sprint.id, name: sprint.name, goal: sprint.goal, start_date: sprint.startDate, end_date: sprint.endDate, status: sprint.status, project_id: projectUuid, created_at: sprint.createdAt, }, { onConflict: 'legacy_id' }); if (error) { console.error(` āŒ Failed to migrate sprint ${sprint.name}:`, error.message); } else { migrated++; console.log(` āœ“ ${sprint.name}`); } } console.log(` āœ… Migrated ${migrated} sprints\n`); return migrated; } async function migrateTasks() { console.log('šŸ“¦ Migrating tasks...'); const tasks = sqliteDb.prepare('SELECT * FROM tasks').all() as Array<{ id: string; title: string; description: string | null; type: string; status: string; priority: string; projectId: string; sprintId: string | null; createdAt: string; updatedAt: string; createdById: string | null; createdByName: string | null; createdByAvatarUrl: string | null; updatedById: string | null; updatedByName: string | null; updatedByAvatarUrl: string | null; assigneeId: string | null; assigneeName: string | null; assigneeEmail: string | null; assigneeAvatarUrl: string | null; dueDate: string | null; comments: string | null; tags: string | null; attachments: string | null; }>; let migrated = 0; let failed = 0; for (const task of tasks) { const projectUuid = projectIdMap.get(task.projectId); if (!projectUuid) { console.log(` āš ļø Skipping task ${task.title} - unknown project: ${task.projectId}`); continue; } const sprintUuid = task.sprintId ? sprintIdMap.get(task.sprintId) : null; const createdByUuid = task.createdById ? userIdMap.get(task.createdById) : null; const updatedByUuid = task.updatedById ? userIdMap.get(task.updatedById) : null; const assigneeUuid = task.assigneeId ? userIdMap.get(task.assigneeId) : null; // Parse JSON fields safely let comments = []; let tags = []; let attachments = []; try { comments = task.comments ? JSON.parse(task.comments) : []; tags = task.tags ? JSON.parse(task.tags) : []; attachments = task.attachments ? JSON.parse(task.attachments) : []; } catch (e) { console.warn(` āš ļø Failed to parse JSON for task ${task.id}:`, e); } const { error } = await supabase .from('tasks') .upsert({ id: generateUUIDFromString(task.id), legacy_id: task.id, title: task.title, description: task.description, type: task.type, status: task.status, priority: task.priority, project_id: projectUuid, sprint_id: sprintUuid, created_at: task.createdAt, updated_at: task.updatedAt, created_by_id: createdByUuid, created_by_name: task.createdByName, created_by_avatar_url: task.createdByAvatarUrl, updated_by_id: updatedByUuid, updated_by_name: task.updatedByName, updated_by_avatar_url: task.updatedByAvatarUrl, assignee_id: assigneeUuid, assignee_name: task.assigneeName, assignee_email: task.assigneeEmail, assignee_avatar_url: task.assigneeAvatarUrl, due_date: task.dueDate, comments: comments, tags: tags, attachments: attachments, }, { onConflict: 'legacy_id' }); if (error) { console.error(` āŒ Failed to migrate task "${task.title}":`, error.message); failed++; } else { migrated++; } } console.log(` āœ… Migrated ${migrated} tasks (${failed} failed)\n`); return migrated; } async function migrateMeta() { console.log('šŸ“¦ Migrating meta data...'); const meta = sqliteDb.prepare("SELECT * FROM meta WHERE key = 'lastUpdated'").get() as { key: string; value: string; } | undefined; if (meta) { const { error } = await supabase .from('meta') .upsert({ key: 'lastUpdated', value: meta.value, updated_at: new Date().toISOString(), }, { onConflict: 'key' }); if (error) { console.error(` āŒ Failed to migrate meta:`, error.message); } else { console.log(` āœ… Migrated lastUpdated: ${meta.value}\n`); } } } async function main() { console.log('šŸš€ Starting SQLite → Supabase migration\n'); console.log(`Supabase URL: ${SUPABASE_URL}\n`); try { // Test connection const { error: healthError } = await supabase.from('users').select('count').limit(1); if (healthError && healthError.code !== 'PGRST116') { // PGRST116 = no rows, which is fine throw new Error(`Cannot connect to Supabase: ${healthError.message}`); } console.log('āœ… Connected to Supabase\n'); // Migration order matters due to foreign keys const stats = { users: await migrateUsers(), sessions: await migrateSessions(), passwordResetTokens: await migratePasswordResetTokens(), projects: await migrateProjects(), sprints: await migrateSprints(), tasks: await migrateTasks(), }; await migrateMeta(); console.log('═══════════════════════════════════════'); console.log('āœ… Migration Complete!'); console.log('═══════════════════════════════════════'); console.log(` Users: ${stats.users}`); console.log(` Sessions: ${stats.sessions}`); console.log(` Password Reset Tokens: ${stats.passwordResetTokens}`); console.log(` Projects: ${stats.projects}`); console.log(` Sprints: ${stats.sprints}`); console.log(` Tasks: ${stats.tasks}`); console.log('═══════════════════════════════════════'); console.log('\nNext steps:'); console.log(' 1. Update your .env.local with Supabase credentials'); console.log(' 2. Test the app locally: npm run dev'); console.log(' 3. Deploy to Vercel with the new environment variables'); } catch (error) { console.error('\nāŒ Migration failed:', error); process.exit(1); } finally { sqliteDb.close(); } } main();