/* 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); const activeDays = startsAt >= Date.parse('2026-10-01T00:00:00+08:00') ? 7 : 4; p = { _id: id, uid: e.uid, accountScope: 'users', periodId: e.targetPeriodId, startsAt, endsAt: startsAt + activeDays * 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 };