/** * One-shot extraction tool for Track D. * * Reads the legacy server/src/database/init.js, pulls out every * CREATE TABLE / CREATE VIRTUAL TABLE statement, categorises each * table into one of 7 migration files (core / academics / finance / * hr / inventory / settings / misc), and writes 7 Knex migration * files under server/src/database/migrations/knex/. * * Each migration uses knex.schema.raw() with the original CREATE TABLE * SQL — this is 100% faithful to the legacy schema, including CHECK * constraints, FTS5 virtual tables, triggers, and anything else the * Knex DSL can't easily express. Future PRs can convert individual * tables to the Knex schema-builder DSL. * * Run: `node tools/extract-migrations.js` from the server/ directory * (or any directory; the script uses absolute paths). */ const fs = require('fs'); const path = require('path'); const REPO = path.resolve(__dirname, '..'); const INIT_JS = path.join(REPO, 'server', 'src', 'database', 'init.js'); const MIGRATIONS_DIR = path.join(REPO, 'server', 'src', 'database', 'migrations', 'knex'); // 7-category mapping per the P1 plan §1.4 D.3. // Tables not matched by any category land in 007_misc. const CATEGORY_BY_NAME = { // 001_core — users, departments, classes, subjects, enrollments, grades, // courses, course_enrolments, assignments, submissions, messages, events, // calendar_events users: '001_core', departments: '001_core', classes: '001_core', subjects: '001_core', enrollments: '001_core', grades: '001_core', courses: '001_core', course_enrolments: '001_core', assignments: '001_core', submissions: '001_core', messages: '001_core', events: '001_core', calendar_events: '001_core', student_grades: '001_core', // users deps password_reset_tokens: '001_core', user_sessions: '001_core', user_devices: '001_core', user_roles: '001_core', // profile-related student_profiles: '001_core', teacher_profiles: '001_core', parent_profiles: '001_core', staff_profiles: '001_core', // 002_academics — exams, marks, attendance, hostel, transport, library, // clubs, crossword, edutainment exams: '002_academics', exam_groups: '002_academics', exam_group_subjects: '002_academics', exam_results: '002_academics', marks: '002_academics', attendance: '002_academics', hostels: '002_academics', rooms: '002_academics', room_assignments: '002_academics', hostel_blocks: '002_academics', hostel_rooms: '002_academics', hostel_allocations: '002_academics', transport_vehicles: '002_academics', transport_routes: '002_academics', transport_stops: '002_academics', transport_assignments: '002_academics', transport_trips: '002_academics', library_books: '002_academics', library_members: '002_academics', library_loans: '002_academics', library_categories: '002_academics', clubs: '002_academics', club_members: '002_academics', club_attendance: '002_academics', crossword_puzzles: '002_academics', crossword_attempts: '002_academics', edutainment_games: '002_academics', edutainment_scores: '002_academics', // additional exam-related question_papers: '002_academics', question_bank: '002_academics', test_papers: '002_academics', test_results: '002_academics', homework: '002_academics', homework_submissions: '002_academics', // 003_finance — fees, payments, invoices, banking, expenses, trips, // fee_plans, fee_plan_installments, discounts, student_fee_discounts, // bank_accounts, bank_reconciliations, bank_reconciliation_lines fees: '003_finance', fee_groups: '003_finance', fee_group_classes: '003_finance', fee_installments: '003_finance', fee_transports: '003_finance', student_fees: '003_finance', student_transport_fees: '003_finance', student_discounts: '003_finance', student_fee_discounts: '003_finance', payments: '003_finance', payment_methods: '003_finance', invoices: '003_finance', invoice_lines: '003_finance', bank_accounts: '003_finance', bank_transactions: '003_finance', bank_reconciliations: '003_finance', bank_reconciliation_lines: '003_finance', expenses: '003_finance', expense_categories: '003_finance', expense_attachments: '003_finance', trips: '003_finance', trip_participants: '003_finance', trip_payments: '003_finance', fee_plans: '003_finance', fee_plan_installments: '003_finance', discounts: '003_finance', discount_codes: '003_finance', chart_of_accounts: '003_finance', finance_categories: '003_finance', // 004_hr — staff_records, leave_requests, staff_attendance, payroll_runs, // payslips, vacancies, applicants staff: '004_hr', staff_records: '004_hr', leave_types: '004_hr', leave_requests: '004_hr', leave_balances: '004_hr', staff_attendance: '004_hr', staff_attendance_records: '004_hr', payroll_runs: '004_hr', payslips: '004_hr', payslip_items: '004_hr', payroll_items: '004_hr', vacancies: '004_hr', vacancy_applications: '004_hr', applicants: '004_hr', hr_employees: '004_hr', hr_departments: '004_hr', // 005_inventory — items, item_stock, stock_transactions, item_issues, // suppliers, store_locations, item_categories items: '005_inventory', item_stock: '005_inventory', stock_transactions: '005_inventory', stock_movements: '005_inventory', item_issues: '005_inventory', item_issue_items: '005_inventory', suppliers: '005_inventory', supplier_contacts: '005_inventory', store_locations: '005_inventory', item_categories: '005_inventory', inventory_items: '005_inventory', inventory_categories: '005_inventory', inventory_locations: '005_inventory', inventory_movements: '005_inventory', // 006_settings — settings, system_settings, school_settings, school_settings_history settings: '006_settings', system_settings: '006_settings', school_settings: '006_settings', school_settings_history: '006_settings', app_settings: '006_settings', user_preferences: '006_settings', // 007_misc — everything else: notices, attachments, audit_logs, // sync_logs, chat_groups, chat_group_members notices: '007_misc', notice_targets: '007_misc', attachments: '007_misc', attachment_links: '007_misc', audit_logs: '007_misc', audit_log: '007_misc', sync_logs: '007_misc', sync_log: '007_misc', sync_conflicts: '007_misc', chat_groups: '007_misc', chat_group_members: '007_misc', chat_messages: '007_misc', // medical medical_records: '007_misc', medical_conditions: '007_misc', medical_allergies: '007_misc', // file attachments polymorphic file_attachments: '007_misc', // cross-cutting notifications: '007_misc', activity_log: '007_misc', // alumni / parent alumni: '007_misc', parent_student_links: '007_misc', // front office visitors: '007_misc', visitor_logs: '007_misc', admission_enquiries: '007_misc', complaints: '007_misc', phone_call_logs: '007_misc', postal_dispatches: '007_misc', postal_receives: '007_misc', // hostel-attendance et al hostel_visits: '007_misc', transport_maintenance: '007_misc', // social social_posts: '007_misc', social_comments: '007_misc', social_likes: '007_misc', social_shares: '007_misc', // e2e test tables test_table: '007_misc', // any other unrecognized }; const CATEGORY_TITLES = { '001_core': 'Core', '002_academics': 'Academics', '003_finance': 'Finance', '004_hr': 'HR & Payroll', '005_inventory': 'Inventory', '006_settings': 'Settings', '007_misc': 'Misc', }; /** * Extract every CREATE TABLE / CREATE VIRTUAL TABLE statement from the * legacy init.js. The init.js is JS, so the SQL is inside backtick * template literals. We need to: * 1. Find each `db.exec(`CREATE TABLE ... `);` block (and similar) * 2. Inside, split on semicolons to get individual statements * 3. Keep only the ones that start with CREATE TABLE / CREATE VIRTUAL TABLE */ function extractCreateStatements(initJsText) { // Find every backtick template literal that contains CREATE TABLE or // CREATE VIRTUAL TABLE. Use a simple scan: find backticks, then inside // them look for SQL keywords. const statements = []; const re = /`((?:\\.|[^`\\])*)`/g; let m; while ((m = re.exec(initJsText)) !== null) { const block = m[1]; // Split on top-level semicolons (newline-terminated). Naive but works // because CREATE TABLE bodies don't contain semicolons. const parts = block.split(/;\s*\n/); for (const raw of parts) { // Strip leading SQL comments (lines starting with --) and blank lines // before trying to match the CREATE TABLE header. const stripped = raw .split('\n') .filter(line => !/^\s*--/.test(line) && !/^\s*$/.test(line)) .join('\n') .trim(); if (!stripped) continue; // Match CREATE [VIRTUAL] TABLE [IF NOT EXISTS] [schema.]name const header = stripped.match(/^CREATE\s+(VIRTUAL\s+)?TABLE\s+(IF\s+NOT\s+EXISTS\s+)?(\[?[\w]+\]?\.)?(\w+)/i); if (!header) continue; const isVirtual = !!header[1]; const tableName = header[4]; // Reconstruct a clean statement with a trailing semicolon. const clean = raw.trim() + ';'; statements.push({ tableName, sql: clean, isVirtual }); } } return statements; } function main() { if (!fs.existsSync(INIT_JS)) { console.error(`init.js not found at ${INIT_JS}`); process.exit(1); } if (!fs.existsSync(MIGRATIONS_DIR)) fs.mkdirSync(MIGRATIONS_DIR, { recursive: true }); const text = fs.readFileSync(INIT_JS, 'utf8'); const stmts = extractCreateStatements(text); console.log(`Extracted ${stmts.length} CREATE TABLE statements from init.js`); // Bucket by category const buckets = Object.fromEntries( Object.keys(CATEGORY_TITLES).map(k => [k, []]) ); const unmapped = []; for (const s of stmts) { const cat = CATEGORY_BY_NAME[s.tableName] || '007_misc'; if (!CATEGORY_BY_NAME[s.tableName]) { unmapped.push(s.tableName); } buckets[cat].push(s); } if (unmapped.length) { console.log(`Unmapped tables (lumped into 007_misc):`); for (const t of [...new Set(unmapped)].sort()) { console.log(` - ${t}`); } } // Emit one Knex migration file per category const now = new Date().toISOString(); for (const [cat, list] of Object.entries(buckets)) { if (list.length === 0) continue; const file = path.join(MIGRATIONS_DIR, `2026071700000${cat.split('_')[0]}_${cat.split('_')[1]}.js`); const title = CATEGORY_TITLES[cat]; const body = renderMigration(cat, title, list); fs.writeFileSync(file, body, 'utf8'); console.log(`Wrote ${file} (${list.length} tables)`); } console.log('Done.'); } function renderMigration(cat, title, list) { // We use knex.schema.raw() to execute the original CREATE TABLE SQL // verbatim. This preserves CHECK constraints, FTS5 virtual tables, // triggers, and anything else the Knex DSL can't express. const lines = []; lines.push('/**'); lines.push(' * Auto-generated by tools/extract-migrations.js on ' + new Date().toISOString()); lines.push(' *'); lines.push(' * Track D (P1-9) — Knex migration for the ' + title + ' concern.'); lines.push(' *'); lines.push(' * Each CREATE TABLE statement is the original SQL from'); lines.push(' * server/src/database/init.js, executed via knex.schema.raw() to'); lines.push(' * preserve CHECK constraints, FTS5 virtual tables, triggers, and'); lines.push(' * other constructs the Knex DSL cannot easily express.'); lines.push(' *'); lines.push(' * Future PRs may convert individual tables to knex.schema.createTable()'); lines.push(' * with the schema-builder DSL.'); lines.push(' */'); lines.push(''); lines.push("exports.up = async function(knex) {"); for (const s of list) { // Sanitize backticks within the SQL — they would break the template // literal. The legacy init.js uses single quotes for string literals // inside SQL, so we mostly just need to handle newlines. const sql = s.sql.replace(/`/g, '\\`'); lines.push(` // ${s.tableName}${s.isVirtual ? ' (virtual)' : ''}`); lines.push(` await knex.schema.raw(${JSON.stringify(sql)});`); lines.push(''); } lines.push('};'); lines.push(''); lines.push('exports.down = async function(knex) {'); // Drop in reverse order for (const s of [...list].reverse()) { lines.push(` await knex.schema.raw('DROP TABLE IF EXISTS ' + ${JSON.stringify(s.tableName)} + ';');`); } lines.push('};'); return lines.join('\n') + '\n'; } main();