migrate-launch-fixes.mjs 3.8 KB

123456789101112131415161718192021
  1. #!/usr/bin/env node
  2. // --plan is offline. --apply is a publisher action and requires a backup plus a real operator session.
  3. import {randomUUID} from 'node:crypto';
  4. import {launchMigrationFiles as files,launchMigrationSql} from './lib/launch-migration.mjs';
  5. const sql=await launchMigrationSql();
  6. const activeSyncSql = `SELECT count(*)::int n FROM "LegacyDirectRun" WHERE status='running'`;
  7. const fields={legacyPasswordMigrationAllowed:{type:'Boolean'},credentialRevision:{type:'Number'},credentialExpectedRevision:{type:'Number'},credentialMigrationAttempt:{type:'Boolean'},passwordResetRequired:{type:'Boolean'}};
  8. if(!process.argv.includes('--apply')){console.log(JSON.stringify({apply:false,files,parseClass:'_User',fields:Object.keys(fields),requires:'Back up database, apply existing payroll and direct-sync prerequisites, then run --apply before publishing gateways.'},null,2));process.exit(0);}
  9. const master=process.env.XIAOSHU_MASTER_KEY,token=process.env.XIAOSHU_MIGRATION_OPERATOR_TOKEN;
  10. if(!master||!token)throw Error('Require XIAOSHU_MASTER_KEY and XIAOSHU_MIGRATION_OPERATOR_TOKEN in environment');
  11. const base=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,''),app=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main';
  12. async function api(path,method='GET',body,session=false){const r=await fetch(base+path,{method,headers:{'Content-Type':'application/json','X-Parse-Application-Id':app,...(session?{'X-Parse-Session-Token':token}:{'X-Parse-Master-Key':master})},body:body?JSON.stringify(body):undefined,signal:AbortSignal.timeout(120000)});const data=await r.json();if(!r.ok)throw Error('Migration API failed ('+r.status+')');return data;}
  13. const user=await api('/users/me','GET',undefined,true);if(user.isDisabled||user.isDeleted||!(user.adminRoleKey==='super-admin'||(user.roles||[]).includes('super-admin')))throw Error('An active super-admin session is required');
  14. const companyId=user.company?.objectId;if(!companyId)throw Error('A scoped company is required');
  15. const schema=await api('/schemas/_User');const missing=Object.fromEntries(Object.entries(fields).filter(([key])=>!schema.fields[key]));if(Object.keys(missing).length)await api('/schemas/_User','PUT',{fields:missing});
  16. const path='xiaoshu/system/launch-fixes-'+randomUUID();let id;const expires=Date.now()+600000;
  17. try{const code=`async function handler(request,response){if(request.user?.id!==${JSON.stringify(user.objectId)}||Date.now()>${expires})return response.status(403).json({success:false});try{await Psql.tx(async t=>{await t.none("SET LOCAL lock_timeout='15s';SET LOCAL statement_timeout='90s'");await t.one("SELECT pg_advisory_xact_lock(hashtext('direct-sync:'||$1)),pg_advisory_xact_lock(hashtext('xs-payroll:'||$1))",[${JSON.stringify(companyId)}]);const running=await t.one(${JSON.stringify(activeSyncSql)});if(running.n)throw Error('Active sync; migration postponed');await t.none(${JSON.stringify(sql)});});return response.json({success:true});}catch(e){return response.status(500).json({success:false,message:String(e.message)});}}`;
  18. new Function(code);
  19. id=(await api('/classes/Function','POST',{name:path,path,code,type:'standalone',enabled:true,params:[],paramList:[],respType:'json'})).objectId;
  20. const r=await fetch(base.replace(/\/parse$/,'/api/functions/')+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{}}),signal:AbortSignal.timeout(120000)});const result=await r.json();if(!r.ok||!result.success)throw Error(result.message||'Migration failed');console.log(JSON.stringify({applied:true,files}));
  21. }finally{if(id){await api('/classes/Function/'+id,'DELETE');const remaining=await api('/classes/Function?'+new URLSearchParams({where:JSON.stringify({objectId:id}),count:'1',limit:'0'}));if(remaining.count)throw Error('Temporary migration helper cleanup failed');}}