/* One-off mongosh repair. Read-only unless apply:true. Never edits historical orders. */ const { createHash } = require('node:crypto'); const key = (...parts) => createHash('sha256').update(JSON.stringify(parts)).digest('hex'); const OLD_IDS = Array.from({ length: 6 }, (_, i) => `gold_miner_vip${i}`); const BACKUPS = 'goldMinerProductIdRepairBackups'; function buildPatch(row) { const patch = {}; const fix = (snapshot, path) => { const index = OLD_IDS.indexOf(snapshot?.productId); if (index < 0) return; if (snapshot.vipLevel !== undefined && snapshot.vipLevel !== index) throw Error(`${row._id}: ${path} productId and vipLevel disagree; review manually`); patch[`${path}.productId`] = `gold_miner_${index}`; }; fix(row.configSnapshot, 'configSnapshot'); fix(row.entitlement?.configSnapshot, 'entitlement.configSnapshot'); if (row.vipConfigSnapshot?.vipTiers !== undefined) { if (!Array.isArray(row.vipConfigSnapshot.vipTiers)) throw Error(`${row._id}: invalid vipTiers`); row.vipConfigSnapshot.vipTiers.forEach((tier, i) => fix(tier, `vipConfigSnapshot.vipTiers.${i}`)); } if (patch['configSnapshot.productId']) { // Exactly matches goldMiner/config.key(configSnapshot), including its outer argument array. patch.configHash = key({ ...row.configSnapshot, productId: patch['configSnapshot.productId'] }); } if (Object.keys(patch).length && (!Number.isSafeInteger(row.revision) || row.revision < 0)) throw Error(`${row._id}: missing/invalid revision; review manually`); return patch; } async function repair(db, { periodIds, apply = false } = {}) { if (typeof apply !== 'boolean') throw Error('apply must be boolean'); if (!Array.isArray(periodIds) || !periodIds.length || periodIds.some(id => typeof id !== 'string' || !/^goldMiner:(?:\d{4}-\d{2}-\d{2}|test:[A-Za-z0-9_:-]+)$/.test(id))) throw Error('Explicit non-empty periodIds required; no unscoped repair is allowed'); const collection = name => typeof db.getCollection === 'function' ? db.getCollection(name) : db.collection(name); const players = collection('goldMinerPlayerPeriods'); const query = { $and: [ { $or: [{ periodId: { $in: periodIds } }, { 'entitlement.sourcePeriodId': { $in: periodIds } }] }, { $or: ['configSnapshot.productId', 'vipConfigSnapshot.vipTiers.productId', 'entitlement.configSnapshot.productId'] .map(path => ({ [path]: { $in: OLD_IDS } })) } ] }; const rows = await players.find(query).toArray(); // Validate the whole selected batch before the first write. const plans = rows.map(row => ({ row, patch: buildPatch(row) })).filter(p => Object.keys(p.patch).length); const report = { apply, periodIds, planned: plans.length, updated: 0, conflicts: [], backupCollection: BACKUPS, fields: {}, samples: [], remaining: plans.length }; for (const { row, patch } of plans) { for (const path of Object.keys(patch)) report.fields[path] = (report.fields[path] || 0) + 1; if (report.samples.length < 20) report.samples.push({ id: row._id, periodId: row.periodId, patch }); if (!apply) continue; // Durable per-version backup must succeed before modifying the player document. await collection(BACKUPS).updateOne({ _id: key('productIdRepairV1', row._id, row.revision, patch) }, { $setOnInsert: { createdAt: new Date(), sourceCollection: 'goldMinerPlayerPeriods', sourceId: row._id, original: row, patch } }, { upsert: true }); const filter = { _id: row._id, revision: row.revision }; // Also guard manual edits which did not increment revision. for (const field of ['configSnapshot', 'configHash', 'vipConfigSnapshot', 'entitlement']) filter[field] = Object.prototype.hasOwnProperty.call(row, field) ? row[field] : { $exists: false }; const result = await players.updateOne(filter, { $set: { ...patch, updatedAt: Date.now() }, $inc: { revision: 1 } }); if (result.modifiedCount === 1) report.updated++; else report.conflicts.push(row._id); } if (apply) report.remaining = await players.countDocuments(query); return report; } module.exports = { repair, buildPatch, BACKUPS };