// One-time migration: copy this app's own data out of the SAP database // (Test_MI-NewDB2) into the new dedicated app database (SAP-ERP), now that // all stores read/write the new DB going forward. Safe to re-run — every // insert is guarded by "does this row already exist in NEW?" before writing. 'use strict'; require('dotenv').config(); const sql = require('mssql'); const oldCfg = { server: process.env.SQL_HOST, port: parseInt(process.env.SQL_PORT), user: process.env.SQL_USER, password: process.env.SQL_PASSWORD, database: process.env.SQL_DATABASE, options: { encrypt: true, trustServerCertificate: true }, }; const newCfg = { server: process.env.APP_SQL_HOST, port: parseInt(process.env.APP_SQL_PORT), user: process.env.APP_SQL_USER, password: process.env.APP_SQL_PASSWORD, database: process.env.APP_SQL_DATABASE, options: { encrypt: true, trustServerCertificate: true }, }; async function getColumns(pool, table) { const r = await pool.request().input('t', table).query(` SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = @t ORDER BY ORDINAL_POSITION `); return r.recordset.map(row => row.COLUMN_NAME); } async function getIdentityColumn(pool, table) { const r = await pool.request().input('t', table).query(` SELECT c.name FROM sys.identity_columns c JOIN sys.tables t ON t.object_id = c.object_id WHERE t.name = @t `); return r.recordset[0]?.name || null; } async function getPkColumns(pool, table) { const r = await pool.request().input('t', table).query(` SELECT c.name FROM sys.indexes i JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id JOIN sys.tables t ON t.object_id = i.object_id WHERE t.name = @t AND i.is_primary_key = 1 ORDER BY ic.key_ordinal `); return r.recordset.map(row => row.name); } // Generic copy: every row from oldTable -> newTable, skipping rows whose PK // already exists in the new table. Preserves IDENTITY values when present. // Only copies columns that exist in BOTH schemas — a handful of app tables // have old, no-longer-referenced columns from past ad-hoc ALTERs that never // made it into the current bootstrap()'s CREATE/ALTER logic; skipping them // is correct since current code doesn't read/write them either way. async function copyTable(oldPool, newPool, table) { const oldCols = await getColumns(oldPool, table); const newColsAll = await getColumns(newPool, table); const newColSet = new Set(newColsAll); const dropped = oldCols.filter(c => !newColSet.has(c)); if (dropped.length) console.log(` ${table}: skipping columns not in new schema: ${dropped.join(', ')}`); const cols = oldCols.filter(c => newColSet.has(c)); const identityCol = await getIdentityColumn(newPool, table); const pkCols = await getPkColumns(newPool, table); const oldRows = (await oldPool.request().query(`SELECT * FROM [${table}]`)).recordset; if (!oldRows.length) { console.log(` ${table}: nothing to copy (old table empty)`); return; } let existingKeys = new Set(); if (pkCols.length) { const newRows = (await newPool.request().query(`SELECT ${pkCols.map(c => `[${c}]`).join(',')} FROM [${table}]`)).recordset; existingKeys = new Set(newRows.map(r => pkCols.map(c => String(r[c])).join('||'))); } const rowsToInsert = oldRows.filter(row => { if (!pkCols.length) return true; return !existingKeys.has(pkCols.map(c => String(row[c])).join('||')); }); const skipped = oldRows.length - rowsToInsert.length; // SET IDENTITY_INSERT is a per-connection session setting, and separate // pooled .request() calls can land on different physical connections // (same class of bug as the SCOPE_IDENTITY() race elsewhere in this // codebase) — so ON, every INSERT, and OFF must all run as ONE batch in // ONE .query() call. SQL Server caps params per request at 2100, so rows // are chunked to stay well under that. let inserted = 0; if (rowsToInsert.length) { const maxParamsPerBatch = 1800; const batchSize = Math.max(1, Math.floor(maxParamsPerBatch / (cols.length || 1))); for (let start = 0; start < rowsToInsert.length; start += batchSize) { const chunk = rowsToInsert.slice(start, start + batchSize); const req = newPool.request(); const stmts = []; if (identityCol) stmts.push(`SET IDENTITY_INSERT [${table}] ON;`); chunk.forEach((row, ri) => { const placeholders = cols.map((c, ci) => { const p = `p${ri}_${ci}`; req.input(p, row[c]); return `@${p}`; }); stmts.push(`INSERT INTO [${table}] (${cols.map(c => `[${c}]`).join(',')}) VALUES (${placeholders.join(',')});`); }); if (identityCol) stmts.push(`SET IDENTITY_INSERT [${table}] OFF;`); await req.query(stmts.join('\n')); inserted += chunk.length; } } console.log(` ${table}: inserted ${inserted}, skipped ${skipped} (already present)`); } async function migrateAdminCollision(oldPool, newPool) { const cols = await getColumns(oldPool, 'ZCUST_USERS'); const oldAdmin = (await oldPool.request().query(`SELECT * FROM [ZCUST_USERS] WHERE USERNAME='admin'`)).recordset[0]; if (!oldAdmin) return; const setCols = cols.filter(c => c !== 'ID'); const req = newPool.request(); setCols.forEach((c, i) => req.input(`p${i}`, oldAdmin[c])); req.input('id', oldAdmin.ID); await req.query(`UPDATE [ZCUST_USERS] SET ${setCols.map((c, i) => `[${c}]=@p${i}`).join(',')} WHERE ID=@id`); console.log(` ZCUST_USERS: 'admin' row overwritten with OLD database's values (per user's choice)`); } async function main() { const oldPool = await new sql.ConnectionPool(oldCfg).connect(); const newPool = await new sql.ConnectionPool(newCfg).connect(); console.log('Connected to both databases.\n'); const simpleTables = [ 'ZWORK_ORDERS', 'ZBATCH_INTIMATIONS', 'ZREQUIREMENTS', 'ZITEM_PORTAL', 'pl_account_map', 'pl_overrides', 'pl_snapshots', 'cf_items', 'salary_uploads', 'salary_cost_config', 'salary_bbcapd_ratio', 'salary_dept_mapping', 'project_requests', ]; console.log('Copying tables...'); for (const t of simpleTables) { try { await copyTable(oldPool, newPool, t); } catch (e) { console.log(` ${t}: ERROR - ${e.message}`); } } console.log('\nZCUST_USERS (admin collision handled specially)...'); await migrateAdminCollision(oldPool, newPool); await copyTable(oldPool, newPool, 'ZCUST_USERS'); // remaining non-colliding users console.log('\nDone.'); process.exit(0); } main().catch(e => { console.error('FATAL', e); process.exit(1); });