migrate-lesson-study.mjs 4.4 KB

123456789101112131415161718192021222324
  1. #!/usr/bin/env node
  2. import {readFileSync,writeFileSync,mkdirSync} from 'node:fs';
  3. import {randomUUID} from 'node:crypto';
  4. const sql=readFileSync(new URL('./sql/lesson-study-batches.sql',import.meta.url),'utf8')+'\n'+readFileSync(new URL('./sql/lesson-workflow.sql',import.meta.url),'utf8').match(/CREATE OR REPLACE FUNCTION xs_lesson_complete\([\s\S]*?END \$\$;/)[0];
  5. if(!process.argv.includes('--apply')){console.log('Plan: install lesson study batches, command journal, canonical record fields and guarded completion; no historical backfill.');process.exit(0);}
  6. const master=process.env.XIAOSHU_MASTER_KEY,token=process.env.XIAOSHU_SYNC_OPERATOR_TOKEN||process.env.XIAOSHU_MIGRATION_OPERATOR_TOKEN,dir=process.env.XIAOSHU_LESSON_STUDY_BACKUP_DIR;
  7. if(!master||!token||!dir)throw Error('Require master key, existing operator session and private backup directory');
  8. mkdirSync(dir,{recursive:true,mode:0o700});
  9. const base=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,''),app=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main',co=process.env.XIAOSHU_COMPANY_ID||'7pIbDBJmKx';
  10. 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 j=await r.json();if(!r.ok||j.error)throw Error('Migration API failed '+r.status);return j;}
  11. const user=await api('/users/me','GET',undefined,true);if(user.company?.objectId!==co||user.isDisabled||user.isDeleted||user.adminRoleKey!=='super-admin')throw Error('Active scoped super-admin required');
  12. const names=[...sql.matchAll(/CREATE OR REPLACE FUNCTION (\w+)/g)].map(m=>m[1]);
  13. const schema=await api('/schemas/DailyStudyRecord');writeFileSync(dir+'/study-schema-before.json',JSON.stringify(schema),{mode:0o600});
  14. const fields={recordScope:{type:'String'},studyVersion:{type:'Number'},canonicalStudyId:{type:'String'}};
  15. const missing=Object.fromEntries(Object.entries(fields).filter(([k])=>!schema.fields[k]));
  16. const path='xiaoshu/system/lesson-study-migrate-'+randomUUID();let helper;
  17. try{
  18. const code=`async function handler(request,response){if(request.user?.id!==${JSON.stringify(user.objectId)}||Date.now()>${Date.now()+600000})return response.status(403).json({success:false});try{const p=request.body.params||{};if(p.apply)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($1)),pg_advisory_xact_lock(hashtext($2))",['direct-sync:'+${JSON.stringify(co)},'xs-credit:'+${JSON.stringify(co)}]);const r=await t.one('SELECT count(*)::int n FROM "LegacyDirectRun" WHERE company=$1 AND status=$2',[${JSON.stringify(co)},'running']);if(r.n)throw Error('Direct sync is running');await t.none(${JSON.stringify(sql)});});return response.json({success:true,data:await Psql.query('SELECT proname,pg_get_functiondef(oid) definition FROM pg_proc WHERE proname=ANY($1::text[])',[${JSON.stringify(names)}])});}catch(e){return response.status(500).json({success:false,message:e.message});}}`;
  19. new Function(code);helper=(await api('/classes/Function','POST',{name:path,path,code,type:'standalone',enabled:true,params:[],paramList:[],respType:'json'})).objectId;
  20. async function call(apply){const r=await fetch(base.replace(/\/parse$/,'/api/functions/')+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{apply}}),signal:AbortSignal.timeout(120000)}),j=await r.json();if(!r.ok||!j.success)throw Error(j.message||'Migration failed');return j.data;}
  21. const before=await call(false);writeFileSync(dir+'/study-routines-before.json',JSON.stringify(before,null,2),{mode:0o600});
  22. if(Object.keys(missing).length)await api('/schemas/DailyStudyRecord','PUT',{fields:missing});
  23. const after=await call(true);if(after.length!==names.length)throw Error('Routine count mismatch');writeFileSync(dir+'/study-routines-after.json',JSON.stringify(after,null,2),{mode:0o600});console.log('Installed '+after.length+' routines; historical lessons unchanged.');
  24. }finally{if(helper){await api('/classes/Function/'+helper,'DELETE');const r=await api('/classes/Function?'+new URLSearchParams({where:JSON.stringify({objectId:helper}),count:'1',limit:'0'}));if(r.count)throw Error('Migration helper cleanup failed');}}