MEGAINFRA-WEBAPP/scripts/migrations/2026-10-05-add-mcp-operations.mjs

44 lines
4 KiB
JavaScript
Raw Normal View History

/** Additive MCP schema. Dry run by default; use --apply only against the selected reviewed database. */
import PocketBase from 'pocketbase';
import {mkdtemp,writeFile} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {join,resolve} from 'node:path';
import {fileURLToPath} from 'node:url';
const text=name=>({name,type:'text',required:true,max:200});
const privateRules={listRule:null,viewRule:null,createRule:null,updateRule:null,deleteRule:null};
export const mcpSchemas=[
{name:'mcp_operations',type:'base',...privateRules,fields:[...['credential','tool','operation_id','payload_hash','status'].map(text),{name:'result',type:'json',maxSize:2000000},{name:'error_code',type:'text',max:200}],indexes:['CREATE UNIQUE INDEX idx_mcp_operation ON mcp_operations (credential, tool, operation_id)']},
{name:'mcp_jobs',type:'base',...privateRules,fields:[...['operation','batch','status'].map(text),{name:'attempts',type:'number',min:0},{name:'next_attempt_at',type:'date'},{name:'error_code',type:'text',max:200}],indexes:['CREATE UNIQUE INDEX idx_mcp_job_operation ON mcp_jobs (operation)']},
{name:'mcp_resource_locks',type:'base',...privateRules,fields:[text('resource_key'),text('owner'),{name:'expires_at',type:'date',required:true}],indexes:['CREATE UNIQUE INDEX idx_mcp_resource ON mcp_resource_locks (resource_key)']},
];
const normalizeIndex=s=>s.replace(/[`\s]/g,'').toLowerCase();
export function validateExistingSchema(existing,desired){
for(const key of Object.keys(privateRules))if(existing[key]!==null)throw Error(`Non-private MCP rule: ${existing.name}.${key}`);
for(const field of desired.fields){const old=existing.fields.find(f=>f.name===field.name);if(old&&(old.type!==field.type||Boolean(old.required)!==Boolean(field.required)||(field.maxSize&&old.maxSize<field.maxSize)||(field.max&&old.max<field.max)))throw Error(`Incompatible field: ${existing.name}.${field.name}`);}
}
async function main(){
if(process.argv.slice(2).some(a=>a!=='--apply'))throw Error('Only --apply is supported');
const pb=new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL);pb.autoCancellation(false);
if(process.env.POCKETBASE_ADMIN_TOKEN)pb.authStore.save(process.env.POCKETBASE_ADMIN_TOKEN);
if(!pb.authStore.isValid)await pb.collection('_superusers').authWithPassword(process.env.POCKETBASE_ADMIN_EMAIL,process.env.POCKETBASE_ADMIN_PASSWORD);
const apply=process.argv.includes('--apply');const backup=apply?await mkdtemp(join(tmpdir(),'mega-mcp-schema-')):null;
for(const desired of mcpSchemas){
let existing;try{existing=await pb.collections.getOne(desired.name);}catch(e){if(e.status!==404)throw e;}
if(existing)validateExistingSchema(existing,desired);
const fields=desired.fields.filter(f=>!existing?.fields.some(old=>old.name===f.name));
const indexes=desired.indexes.filter(i=>!existing?.indexes.some(old=>normalizeIndex(old)===normalizeIndex(i)));
console.log(JSON.stringify({collection:desired.name,create:!existing,addFields:fields.map(f=>f.name),addIndexes:indexes.length,apply}));
if(!apply)continue;
if(existing){
await writeFile(join(backup,desired.name+'.json'),JSON.stringify(existing,null,2),{mode:0o600});
const current=await pb.collections.getOne(existing.id);if(JSON.stringify(current)!==JSON.stringify(existing))throw Error('Schema changed during migration');
await pb.collections.update(existing.id,{fields:[...existing.fields,...fields],indexes:[...existing.indexes,...indexes]});
}else await pb.collections.create(desired);
const after=await pb.collections.getOne(desired.name);validateExistingSchema(after,desired);
for(const field of desired.fields)if(!after.fields.some(f=>f.name===field.name))throw Error('Missing field after migration');
for(const index of desired.indexes)if(!after.indexes.some(i=>normalizeIndex(i)===normalizeIndex(index)))throw Error('Missing index after migration');
}
if(backup)console.log('Schema backups: '+backup);
}
if(process.argv[1]&&resolve(process.argv[1])===fileURLToPath(import.meta.url))main().catch(e=>{console.error(e.status?`PocketBase HTTP ${e.status}`:e.message);process.exitCode=1;});