444 lines
13 KiB
TypeScript
444 lines
13 KiB
TypeScript
#!/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<string, string>();
|
|
const projectIdMap = new Map<string, string>();
|
|
const sprintIdMap = new Map<string, string>();
|
|
|
|
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();
|