release.mjs 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
  1. // Selective release. SQL adds derived structures only; authoritative definitions are pinned.
  2. import fs from 'node:fs';import {createHash} from 'node:crypto';import {parse,withGateway} from './server-util.mjs';import {localCandidates,patchFunctions,MOBILE_HELPERS,MONTHLY_HELPERS,OPERATIONS_HELPERS} from '../../scripts/speed-round3-patch.mjs';
  3. const root='/Users/mac/.local/state/xiaoshu/cloud-releases/2026-11-10-speed-round3',action=process.argv[2];
  4. const files=['payroll-source-index.sql','payroll-read-snapshot.sql','payroll-cutoff-bulk.sql','teacher-earned-months.sql'];
  5. const sql=files.map(name=>fs.readFileSync('scripts/sql/'+name,'utf8').replace(/^BEGIN;\s*$/gm,'').replace(/^COMMIT;\s*$/gm,'')).join('\n');
  6. const originals=JSON.parse(fs.readFileSync(root+'/original-definitions.json')).map(({proname,definition})=>({proname,definition})).sort((a,b)=>(a.proname+a.definition).localeCompare(b.proname+b.definition));
  7. const names=[...new Set(originals.map(x=>x.proname))],newNames=[...new Set([...sql.matchAll(/CREATE OR REPLACE FUNCTION\s+(?:public\.)?(\w+)/g)].map(x=>x[1])), 'xs_payroll_read_snapshot_authority_ready'];
  8. const sha=x=>createHash('sha256').update(x).digest('hex');
  9. const query=`SELECT proname,pg_get_functiondef(p.oid) AS definition FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace WHERE n.nspname='public' AND proname=ANY($1::text[])`;
  10. const rollback=`DROP TRIGGER IF EXISTS xs_payroll_source_index_row ON "CommonModel";DROP TRIGGER IF EXISTS xs_payroll_source_index_truncate ON "CommonModel";
  11. DROP TRIGGER IF EXISTS xs_payroll_source_index_row ON "CourseAppointment";DROP TRIGGER IF EXISTS xs_payroll_source_index_truncate ON "CourseAppointment";
  12. DROP TRIGGER IF EXISTS xs_payroll_source_index_row ON "LessonRecord";DROP TRIGGER IF EXISTS xs_payroll_source_index_truncate ON "LessonRecord";
  13. DROP TRIGGER IF EXISTS xs_payroll_source_index_row ON "LessonRecordFact";DROP TRIGGER IF EXISTS xs_payroll_source_index_truncate ON "LessonRecordFact";
  14. DROP TABLE IF EXISTS "PayrollSourceIndex","PayrollSourceIndexMeta","TeacherEarnedMonth";
  15. DROP INDEX IF EXISTS payroll_source_index_common59_digest;`;
  16. async function run(p){
  17. const canonical=rows=>rows.sort((a,b)=>(a.proname+a.definition).localeCompare(b.proname+b.definition));
  18. return Psql.tx(async tx=>{
  19. await tx.none("SET LOCAL lock_timeout='3s';SET LOCAL statement_timeout='120s';");
  20. if(JSON.stringify(canonical(await tx.query(config.query,[config.names])))!==JSON.stringify(config.originals))throw Error('Original SQL definitions drifted');
  21. if(!(await tx.one('SELECT xs_payroll_digest_ready() AND xs_payroll_authority_ready() AS ready')).ready)throw Error('Existing digest/authority guard is not ready');
  22. if(p.action==='install'){
  23. if((await tx.one('SELECT EXISTS(SELECT 1 FROM "LegacyDirectRun" WHERE status=$1) active',['running'])).active)throw Error('Synchronization must be idle');
  24. if((await tx.one(`SELECT to_regclass('"PayrollSourceIndex"') IS NOT NULL OR to_regclass('"PayrollSourceIndexMeta"') IS NOT NULL OR to_regclass('"TeacherEarnedMonth"') IS NOT NULL OR to_regclass('payroll_source_index_common59_digest') IS NOT NULL AS present`)).present)throw Error('Derived infrastructure already present');
  25. if((await tx.one("SELECT EXISTS(SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace WHERE n.nspname='public' AND proname=ANY($1::text[])) AS present",[config.newNames])).present)throw Error('A proposed helper already exists');
  26. if((await tx.one("SELECT EXISTS(SELECT 1 FROM pg_trigger WHERE tgrelid=ANY(ARRAY['\"CommonModel\"'::regclass,'\"CourseAppointment\"'::regclass,'\"LessonRecord\"'::regclass,'\"LessonRecordFact\"'::regclass]) AND tgname IN ('xs_payroll_source_index_row','xs_payroll_source_index_truncate')) AS present")).present)throw Error('A proposed trigger already exists');
  27. await tx.any(config.sql);
  28. }else if(p.action==='rehearse'||p.action==='rollback'){
  29. if(p.action==='rehearse'&&!(await tx.one(`SELECT to_regclass('\"PayrollSourceIndex\"') IS NOT NULL AND to_regclass('\"PayrollSourceIndexMeta\"') IS NOT NULL AND to_regclass('\"TeacherEarnedMonth\"') IS NOT NULL AND to_regprocedure('xs_payroll_read_snapshot(text,text,boolean,boolean)') IS NOT NULL AS present`)).present)throw Error('Install first; an empty rollback is not a rehearsal');
  30. await tx.none(config.rollback);
  31. const functions=await tx.query("SELECT p.oid::regprocedure::text AS identity FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace WHERE n.nspname='public' AND proname=ANY($1::text[])",[config.newNames]);
  32. for(const fn of functions)await tx.none('DROP FUNCTION IF EXISTS '+fn.identity);
  33. if(JSON.stringify(canonical(await tx.query(config.query,[config.names])))!==JSON.stringify(config.originals))throw Error('Rollback changed authoritative SQL');
  34. if(p.action==='rehearse')throw Error('ROLLBACK_REHEARSAL_OK');return{rolledBack:true};
  35. }
  36. if(JSON.stringify(canonical(await tx.query(config.query,[config.names])))!==JSON.stringify(config.originals))throw Error('Migration changed authoritative SQL');
  37. const ready=await tx.one('SELECT xs_payroll_digest_ready() digest,xs_payroll_source_index_ready() source,xs_payroll_read_snapshot_authority_ready() snapshot');
  38. if(!ready.digest||!ready.source||!ready.snapshot)throw Error('Installed guard not ready');
  39. const definitions=await tx.query("SELECT proname,pg_get_function_identity_arguments(p.oid) args,pg_get_functiondef(p.oid) definition FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace WHERE n.nspname='public' AND proname=ANY($1::text[]) ORDER BY proname,args",[config.newNames]);
  40. return{ready,definitions,originalFunctionsUnchanged:true};
  41. });
  42. }
  43. const source='const config='+JSON.stringify({sql,originals,names,newNames,query,rollback})+';'+run.toString();
  44. if(action==='prepare'){
  45. const candidates=localCandidates(),manifest={sqlHash:sha(sql),rollbackHash:sha(rollback),scopes:{}};
  46. for(const [scope,baseFile,helpers]of[['mobile','mobile-gateway.json',[...MOBILE_HELPERS,...MONTHLY_HELPERS]],['operations','admin-gateway.json',OPERATIONS_HELPERS]]){
  47. const baseline=JSON.parse(fs.readFileSync(root+'/'+baseFile)),result=patchFunctions(baseline.code,candidates[scope],helpers);
  48. fs.writeFileSync(root+'/'+scope+'-candidate.json',JSON.stringify({...baseline,code:result.code}),{mode:0o600});manifest.scopes[scope]={id:baseline.objectId,...result.audit};
  49. }
  50. fs.writeFileSync(root+'/infrastructure.sql',sql,{mode:0o600});fs.writeFileSync(root+'/rollback.sql',rollback,{mode:0o600});fs.writeFileSync(root+'/manifest.json',JSON.stringify(manifest,null,2),{mode:0o600});console.log(JSON.stringify({prepared:true,sqlHash:manifest.sqlHash,scopes:Object.keys(manifest.scopes)}));
  51. }else if(['install','check','rehearse','rollback'].includes(action)){
  52. const manifest=JSON.parse(fs.readFileSync(root+'/manifest.json'));if(manifest.sqlHash!==sha(sql)||manifest.rollbackHash!==sha(rollback))throw Error('Reviewed migration changed');
  53. const result=await withGateway(source,async call=>{try{return await call({action});}catch(e){if(action==='rehearse'&&e.message==='ROLLBACK_REHEARSAL_OK')return{rehearsed:true};throw e;}});
  54. fs.writeFileSync(root+'/'+action+'-receipt.json',JSON.stringify(result,null,2),{mode:0o600});console.log(JSON.stringify({...result,definitions:result.definitions?.length}));
  55. }else if(action==='publish'){
  56. const manifest=JSON.parse(fs.readFileSync(root+'/manifest.json'));
  57. for(const scope of ['operations','mobile']){
  58. const saved=manifest.scopes[scope],candidate=JSON.parse(fs.readFileSync(root+'/'+scope+'-candidate.json')),live=await parse('/classes/Function/'+saved.id);
  59. if(sha(live.code)!==saved.baselineHash||sha(candidate.code)!==saved.candidateHash)throw Error('Gateway drift or candidate changed: '+scope);
  60. }
  61. const receipts=[];for(const scope of ['operations','mobile']){const saved=manifest.scopes[scope],candidate=JSON.parse(fs.readFileSync(root+'/'+scope+'-candidate.json'));await parse('/classes/Function/'+saved.id,'PUT',{code:candidate.code});const readback=await parse('/classes/Function/'+saved.id);if(sha(readback.code)!==saved.candidateHash)throw Error('Gateway readback mismatch: '+scope);receipts.push({scope,hash:saved.candidateHash});}
  62. fs.writeFileSync(root+'/publish-receipt.json',JSON.stringify(receipts,null,2),{mode:0o600});console.log(JSON.stringify(receipts));
  63. }else throw Error('Expected prepare/install/check/rehearse/publish/rollback');