import Database from 'better-sqlite3'; import { drizzle } from 'drizzle-orm/better-sqlite3'; import * as schema from './schema'; import path from 'path'; import fs from 'fs'; // Lazy initialization to avoid database access during build let _db: ReturnType | null = null; let _sqlite: Database.Database | null = null; function getDb() { if (!_db) { // Ensure data directories exist const dataDir = path.join(process.cwd(), 'data'); const dbDir = path.join(dataDir, 'db'); const uploadsDir = path.join(dataDir, 'uploads'); if (!fs.existsSync(dbDir)) { fs.mkdirSync(dbDir, { recursive: true }); } if (!fs.existsSync(uploadsDir)) { fs.mkdirSync(uploadsDir, { recursive: true }); } // Database file path const dbPath = path.join(dbDir, 'wishlist.db'); // Create SQLite database connection _sqlite = new Database(dbPath); _sqlite.pragma('journal_mode = WAL'); // Better concurrency // Create Drizzle instance _db = drizzle(_sqlite, { schema }); } return _db; } // Export db as a getter export const db = new Proxy({} as ReturnType, { get(target, prop) { return getDb()[prop as keyof ReturnType]; } }); // Initialize database (create tables and seed if needed) export async function initializeDatabase() { try { // Ensure database is initialized const sqlite = _sqlite || getDb() && _sqlite; if (!sqlite) throw new Error('Failed to initialize database'); // Create wishlists table sqlite.exec(` CREATE TABLE IF NOT EXISTS wishlists ( id TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, description TEXT, preferences TEXT, image_url TEXT, notes TEXT, is_public INTEGER DEFAULT 0 NOT NULL, sort_order INTEGER DEFAULT 0 NOT NULL, created_at INTEGER DEFAULT (unixepoch()) NOT NULL, updated_at INTEGER DEFAULT (unixepoch()) NOT NULL ) `); // Create wishlist_items table sqlite.exec(` CREATE TABLE IF NOT EXISTS wishlist_items ( id TEXT PRIMARY KEY NOT NULL, wishlist_id TEXT NOT NULL, name TEXT NOT NULL, description TEXT, price REAL, currency TEXT DEFAULT 'USD' NOT NULL, quantity INTEGER DEFAULT 1 NOT NULL, images TEXT, purchase_urls TEXT, notes TEXT, is_archived INTEGER DEFAULT 0 NOT NULL, claimed_by_name TEXT, claimed_by_note TEXT, claimed_by_token TEXT UNIQUE, claimed_at INTEGER, is_purchased INTEGER DEFAULT 0 NOT NULL, sort_order INTEGER DEFAULT 0 NOT NULL, created_at INTEGER DEFAULT (unixepoch()) NOT NULL, updated_at INTEGER DEFAULT (unixepoch()) NOT NULL, FOREIGN KEY (wishlist_id) REFERENCES wishlists(id) ON DELETE CASCADE ) `); // Create settings table sqlite.exec(` CREATE TABLE IF NOT EXISTS settings ( id TEXT PRIMARY KEY NOT NULL, key TEXT NOT NULL UNIQUE, value TEXT NOT NULL, updated_at INTEGER DEFAULT (unixepoch()) NOT NULL ) `); // Create guests table sqlite.exec(` CREATE TABLE IF NOT EXISTS guests ( id TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL, created_at INTEGER DEFAULT (unixepoch()) NOT NULL, updated_at INTEGER DEFAULT (unixepoch()) NOT NULL ) `); // Create item_claims table sqlite.exec(` CREATE TABLE IF NOT EXISTS item_claims ( id TEXT PRIMARY KEY NOT NULL, item_id TEXT NOT NULL, guest_id TEXT NOT NULL, quantity INTEGER DEFAULT 1 NOT NULL, note TEXT, is_purchased INTEGER DEFAULT 0 NOT NULL, claimed_at INTEGER DEFAULT (unixepoch()) NOT NULL, updated_at INTEGER DEFAULT (unixepoch()) NOT NULL, FOREIGN KEY (item_id) REFERENCES wishlist_items(id) ON DELETE CASCADE, FOREIGN KEY (guest_id) REFERENCES guests(id) ON DELETE CASCADE, UNIQUE(item_id, guest_id) ) `); sqlite.exec('CREATE INDEX IF NOT EXISTS idx_item_claims_item ON item_claims(item_id)'); sqlite.exec('CREATE INDEX IF NOT EXISTS idx_item_claims_guest ON item_claims(guest_id)'); // Run migrations for existing databases try { const columns = sqlite.pragma('table_info(wishlists)') as Array<{ name: string }>; // Add image_url column if it doesn't exist const hasImageUrl = columns.some((col) => col.name === 'image_url'); if (!hasImageUrl) { sqlite.exec('ALTER TABLE wishlists ADD COLUMN image_url TEXT'); console.log('✅ Added image_url column to wishlists table'); } // Add sort_order column if it doesn't exist const hasSortOrder = columns.some((col) => col.name === 'sort_order'); if (!hasSortOrder) { sqlite.exec('ALTER TABLE wishlists ADD COLUMN sort_order INTEGER DEFAULT 0 NOT NULL'); console.log('✅ Added sort_order column to wishlists table'); } // Add preferences column if it doesn't exist const hasPreferences = columns.some((col) => col.name === 'preferences'); if (!hasPreferences) { sqlite.exec('ALTER TABLE wishlists ADD COLUMN preferences TEXT'); console.log('✅ Added preferences column to wishlists table'); } const itemColumns = sqlite.pragma('table_info(wishlist_items)') as Array<{ name: string }>; const hasClaimedByGuestId = itemColumns.some((col) => col.name === 'claimed_by_guest_id'); if (!hasClaimedByGuestId) { sqlite.exec('ALTER TABLE wishlist_items ADD COLUMN claimed_by_guest_id TEXT REFERENCES guests(id) ON DELETE SET NULL'); console.log('✅ Added claimed_by_guest_id column to wishlist_items'); } const hasClaimedByName = itemColumns.some((col) => col.name === 'claimed_by_name'); const hasClaimedByToken = itemColumns.some((col) => col.name === 'claimed_by_token'); if (hasClaimedByName || hasClaimedByToken) { if (hasClaimedByName) { sqlite.exec('UPDATE wishlist_items SET claimed_by_name = NULL'); } if (hasClaimedByToken) { sqlite.exec('UPDATE wishlist_items SET claimed_by_token = NULL'); } console.log('ℹ️ Legacy claim columns nullified (claimed_by_name / claimed_by_token)'); } // Move any existing inline claims into item_claims (forward-migration), then nullify the inline columns const itemColsAfter = sqlite.pragma('table_info(wishlist_items)') as Array<{ name: string }>; const stillHasClaimedByGuestId = itemColsAfter.some((c) => c.name === 'claimed_by_guest_id'); if (stillHasClaimedByGuestId) { sqlite.exec(` INSERT INTO item_claims (id, item_id, guest_id, quantity, note, is_purchased, claimed_at, updated_at) SELECT lower(hex(randomblob(16))), wi.id, wi.claimed_by_guest_id, 1, wi.claimed_by_note, wi.is_purchased, COALESCE(wi.claimed_at, unixepoch()), unixepoch() FROM wishlist_items wi WHERE wi.claimed_by_guest_id IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM item_claims ic WHERE ic.item_id = wi.id AND ic.guest_id = wi.claimed_by_guest_id ) `); sqlite.exec('UPDATE wishlist_items SET claimed_by_guest_id = NULL, claimed_by_note = NULL, claimed_at = NULL, is_purchased = 0'); console.log('ℹ️ Migrated inline claims into item_claims and cleared inline claim columns'); } } catch (migrationError) { console.log('Migration already applied or not needed'); } // Auto-seed database if empty const { seedDatabase } = await import('./seed'); await seedDatabase(); return true; } catch (error) { console.error('❌ Database initialization failed:', error); throw error; } } // Export schema for use in other files export * from './schema';