72 lines
4.1 KiB
JavaScript
72 lines
4.1 KiB
JavaScript
/* 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 };
|