269 lines
12 KiB
JavaScript
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();
|