import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import {fileURLToPath} from 'node:url'; const ROOT=path.dirname(path.dirname(fileURLToPath(import.meta.url))); const sha=x=>crypto.createHash('sha256').update(x).digest('hex'); const fail=code=>{throw Object.assign(Error(code),{code});}; const targets={ksfRegisterSource:'czT35BZZlx',ksfValidateImport:'ra9OWsmbtC',ksfSubmitImport:'gPjyoZbHhU',ksfGetIngestContract:'dWSrHp4FfQ',ksfCheckDuplicates:'JDNsVUCXSo'}; export async function deployDedup({root=ROOT,request,apply=false}={}){ const manifest=JSON.parse(fs.readFileSync(path.join(root,'dist/dedup-manifest.json'),'utf8')); if(manifest.version!=='ksf.dedup-deployment.v1'||manifest.appId!=='FKieRbIoGt_main'||JSON.stringify(manifest.functions.map(x=>x.name).sort())!==JSON.stringify(Object.keys(targets).sort()))fail('INVALID_DEPLOY_MANIFEST'); const entries=manifest.functions.map(x=>{ if(x.objectId!==targets[x.name]||x.path!=='/'+x.name||x.file!=='dist/'+x.name+'.js'||!Array.isArray(x.beforeSha256)||x.beforeSha256.some(h=>!/^[a-f0-9]{64}$/.test(h)))fail('INVALID_DEPLOY_MANIFEST'); const code=fs.readFileSync(path.join(root,x.file),'utf8');if(sha(code)!==x.sha256)fail('PACKAGE_HASH_MISMATCH');new Function(code);return{...x,code}; }); const find=async x=>{ const query=new URLSearchParams({where:JSON.stringify({$or:[{name:x.name},{path:x.path}]}),limit:'3'}); const rows=(await request('GET','/classes/Function?'+query)).results; if(!Array.isArray(rows)||rows.length>1)fail('FUNCTION_IDENTITY_MISMATCH'); const row=rows[0];if(row&&(row.name!==x.name||row.path!==x.path||typeof row.code!=='string'||x.objectId&&row.objectId!==x.objectId))fail('FUNCTION_IDENTITY_MISMATCH');return row||null; }; const schemas=await request('GET','/schemas'),before=[]; for(const x of entries){const row=await find(x);if(x.objectId&&!row)fail('FUNCTION_IDENTITY_MISMATCH');if(row&&![...x.beforeSha256,x.sha256].includes(sha(row.code)))fail('UNKNOWN_FUNCTION_VERSION');before.push(row);} const journal={ok:true,readOnly:!apply,operation:apply?'dedup-functions-published':'read-only-plan',schemaChanges:0,permissionChanges:0,businessWrites:0,functionCodeWrites:0,frontendChanged:false,gatewayModified:false,functions:entries.map((x,i)=>({name:x.name,objectId:before[i]?.objectId||null,beforeSha256:before[i]?sha(before[i].code):null,afterSha256:x.sha256,action:before[i]?(before[i].code===x.code?'unchanged':'update'):'create'}))}; if(!apply)return journal; const evidence=path.join(root,'evidence');fs.mkdirSync(evidence,{recursive:true});journal.backup=path.join(evidence,'dedup-functions-before-'+crypto.randomUUID()+'.json');fs.writeFileSync(journal.backup,JSON.stringify(before,null,2),{flag:'wx',mode:0o600}); const attempted=[]; try{ for(const [i,x]of entries.entries()){ if(journal.functions[i].action==='unchanged')continue; const current=await find(x),prior=before[i];if((current?.code||null)!==(prior?.code||null))fail('FUNCTION_CHANGED_CONCURRENTLY'); const item={x,prior,id:prior?.objectId||null};attempted.push(item); if(prior)await request('PUT','/classes/Function/'+item.id,{code:x.code}); else item.id=(await request('POST','/classes/Function',{name:x.name,path:x.path,code:x.code,ACL:{}})).objectId; if(!item.id)fail('FUNCTION_CREATE_UNCERTAIN'); journal.functionCodeWrites++;const read=await request('GET','/classes/Function/'+item.id);if(read.code!==x.code||read.name!==x.name||read.path!==x.path)fail('FUNCTION_READBACK_MISMATCH');Object.assign(journal.functions[i],{objectId:item.id,verified:true}); } if(JSON.stringify((await request('GET','/schemas')).results)!==JSON.stringify(schemas.results))fail('SCHEMA_OR_PERMISSIONS_CHANGED'); journal.schemaAndPermissionsPreserved=true;journal.finishedAt=new Date().toISOString();fs.writeFileSync(path.join(evidence,'dedup-deployment.json'),JSON.stringify(journal,null,2),{mode:0o600});return journal; }catch(error){ let complete=true; for(const item of attempted.reverse())try{ const {x,prior}=item; // A lost create response has no trusted objectId: preserve it for manual reconciliation. if(!item.id){complete=false;continue;} const current=await request('GET','/classes/Function/'+item.id); if(prior&¤t.code===prior.code)continue; if(current.name!==x.name||current.path!==x.path||current.code!==x.code){complete=false;continue;} if(prior){await request('PUT','/classes/Function/'+item.id,{code:prior.code});if((await request('GET','/classes/Function/'+item.id)).code!==prior.code)complete=false;} else await request('DELETE','/classes/Function/'+item.id); }catch{complete=false;} error.receipt={...journal,ok:false,error:{code:error.code||'DEPLOY_FAILED'},rollbackComplete:complete};fs.writeFileSync(path.join(evidence,'dedup-deployment.json'),JSON.stringify(error.receipt,null,2),{mode:0o600});throw error; } } if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)){ try{if(process.argv.slice(2).some(x=>x!=='--apply')||process.argv.length>3)fail('USAGE');const {request}=await import('./platform-client.mjs');console.log(JSON.stringify(await deployDedup({request,apply:process.argv.includes('--apply')}),null,2));} catch(e){console.error(JSON.stringify(e.receipt||{ok:false,error:{code:/^[A-Z][A-Z0-9_]+$/.test(e.code||'')?e.code:'DEPLOY_STOPPED',message:'查重部署停止,保留现场,不输出配置或原始异常。'}}));process.exitCode=1;} }