server/laf-cloud/functions/activityConfig/migrate.cjs

120 lines
7.0 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);
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 };