/* Offline migration, invoked by an operator in mongosh. Default is read-only. const {migrate}=require('C:/.../activityConfig/migrate.cjs'); await migrate(db,{apply:false}); */ const { createHash } = require('node:crypto'); const key = (...parts) => createHash('sha256').update(JSON.stringify(parts)).digest('hex'); const same = (a,b) => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b)); function canonical(v) { if (Array.isArray(v)) return v.map(canonical); if (v && typeof v === 'object' && !(v instanceof Date)) return Object.fromEntries(Object.keys(v).sort().map(k=>[k,canonical(v[k])])); return v; } async function migrate(db, {apply=false}={}) { const collection = name => typeof db.getCollection==='function' ? db.getCollection(name) : db.collection(name); const read = name => collection(name).find({}).toArray(); const merge = async names => { const rows=new Map(); for(const name of names) for(const row of await read(name)) { if(rows.has(row._id) && !same(rows.get(row._id),row)) { if(name==='gold_miner_player_periods') { const current=rows.get(row._id), migrated=new Set(['initialized','entitlement','entitlementNextRetryAt','settlementId', 'settlementPreparedAt','settlementAcknowledgedAt','settlementNextRetryAt']); if(Object.keys(row).every(k=>migrated.has(k)||same(current[k],row[k]))) continue; } throw Error('Conflicting source rows: '+name+'/'+row._id); } rows.set(row._id,row); } return rows; }; const operations=[]; const configs=new Map((await read('activityConfigs')).map(r=>[r._id,r])); const originalPlayers=new Map((await read('goldMinerPlayerPeriods')).map(r=>[r._id,r])); const players=await merge(['goldMinerPlayerPeriods','gold_miner_player_periods']); // Copy before modifying so a dry-run does not mutate driver/mock source objects. for(const [id,row] of players) players.set(id,structuredClone(row)); const addConfig = row => { const old=configs.get(row._id); if(old && !same(old,row)) throw Error('Configuration conflict: '+row._id); if(!old) { configs.set(row._id,row); operations.push({table:'activityConfigs',row}); } }; for(const c of (await merge(['goldMinerConfigs','gold_miner_configs'])).values()) { const { _id,status,createdAt,configVersion,publishedAt,...config }=c; const effectiveFrom=Date.parse(config.effectiveFromPeriodId?.slice(10)+'T00:00:00+08:00'); if(!Number.isFinite(effectiveFrom) || !configVersion || !Number.isSafeInteger(publishedAt)) throw Error('Invalid gold miner config: '+_id); addConfig({_id:key('goldMiner',configVersion),activityId:'goldMiner',recordType:'version',configVersion, status:status||'published',effectiveFrom,publishedAt,config}); } for(const p of (await merge(['goldMinerPeriods','gold_miner_periods'])).values()) { if(!p.configSnapshot || !p.periodId) throw Error('Invalid period: '+p._id); addConfig({_id:key('goldMinerControl',p.periodId),activityId:'goldMiner',recordType:'control',periodId:p.periodId, purchaseEnabled:p.purchaseEnabled!==false,frozenConfig:p.configSnapshot}); } for(const c of await read('cloudRiseConfig')) { if(typeof c.periodId!=='string' || !Number.isSafeInteger(c.startsAt) || !Number.isSafeInteger(c.endsAt) || c.startsAt>=c.endsAt) throw Error('Invalid cloudRise config: '+c._id); const configVersion='legacy:'+c.periodId; addConfig({_id:key('cloudRise',configVersion),activityId:'cloudRise',recordType:'version',configVersion,status:'published', effectiveFrom:c.startsAt,publishedAt:c.startsAt-1,config:{periodId:c.periodId,startsAt:c.startsAt,endsAt:c.endsAt, unlockLevel:c.unlockLevel,durationHours:c.durationHours??24,pools:c.pools??[10000,15000,20000]}}); } for(const e of (await merge(['goldMinerEntitlements','gold_miner_entitlements'])).values()) { const id=key('users',e.uid,e.targetPeriodId); let p=players.get(id); if(!p) { if(e.state!=='reserved') throw Error('Active entitlement missing player: '+e._id); const startsAt=Date.parse(e.targetPeriodId.slice(10)+'T00:00:00+08:00'); if(!Number.isFinite(startsAt)) throw Error('Invalid entitlement period: '+e._id); p={_id:id,uid:e.uid,accountScope:'users',periodId:e.targetPeriodId,startsAt,endsAt:startsAt+4*86400000, entitlementStatus:'none',revision:0,createdAt:e.createdAt};players.set(id,p); } if(p.entitlement && p.entitlement.sourceOrderNo!==e.sourceOrderNo) throw Error('Entitlement conflict: '+id); // Never regress an embedded entitlement on a repeated migration. if(!p.entitlement) p.entitlement=structuredClone(e); p.entitlementNextRetryAt??=0; } for(const g of (await merge(['goldMinerRewardGrants','gold_miner_reward_grants'])).values()) { const p=players.get(key('users',g.uid,g.periodId)), task=p?.tasks?.find(t=>t.taskId===g.taskId); if(!task || task.grantId!==g._id || !same(task.itemsSnapshot,g.itemsSnapshot) || (g.status==='client_saved' && task.claimStatus!=='claimed')) throw Error('Reward/player mismatch: '+g._id); } for(const r of (await merge(['goldMinerSettlements','gold_miner_settlements'])).values()) { const p=players.get(key('users',r.uid,r.periodId)); if(!p || r._id!==key('settlement',p._id) || !same(p.settlementTaskIds,r.taskIds)) throw Error('Settlement/player mismatch: '+r._id); p.settlementAcknowledgedAt??=r.acknowledgedAt??null; } for(const p of players.values()) { if(p._id!==key('users',p.uid,p.periodId)) throw Error('Invalid player identity: '+p._id); if(p.tasks) { p.initialized=true; p.settlementId=key('settlement',p._id); p.settlementAcknowledgedAt??=null; // Old unpaid closures must be rechecked after a later payment; old unassigned tasks need server authorization. if(!p.settlementPreparedAt || p.tasks.some(t=>p.settlementTaskIds?.includes(t.taskId)&&t.claimStatus==='unclaimed')) p.settlementPreparedAt=null; p.settlementNextRetryAt??=0; if(p.entitlementStatus==='unlocked' && !p.entitlement) throw Error('Unlocked player missing entitlement: '+p._id); } if(!same(originalPlayers.get(p._id),p)) operations.push({table:'goldMinerPlayerPeriods',row:p}); } // Validate every source before any write. Never delete old collections or touch users/order. if(apply) for(const op of operations) { const {_id,...fields}=op.row; await collection(op.table).updateOne({_id},{$set:fields},{upsert:true}); } return {dryRun:!apply,activityConfigs:configs.size,playerPeriods:players.size,writes:operations.length, oldCollectionsDeleted:false}; } module.exports={migrate};