geocrop-platform./apps/nextgen/tools/extract-migrations.js

269 lines
12 KiB
JavaScript

/**
* 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();