server/laf-cloud/functions/activityConfig/migrate.cjs
2026-09-24 17:59:52 +08:00

107 lines
6.5 KiB
JavaScript

/* 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};