server/laf-cloud/functions/goldMiner/repair-product-ids.cjs

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