/* eslint-disable no-await-in-loop */ // This script syncs this tachi instances database up with the tachi-database-seeds. import { Command } from "commander"; import db, { monkDB } from "external/mongo/db"; import fjsh from "fast-json-stable-hash"; import { PullDatabaseSeeds } from "lib/database-seeds/repo"; import CreateLogCtx from "lib/logger/logger"; import UpdateIsPrimaryStatus from "lib/score-mutation/update-isprimary"; import { TachiConfig } from "lib/setup/config"; import { RemoveStaleFolderShowcaseStats } from "lib/showcase/showcase"; import { UpdateMilestoneSubscriptions } from "lib/targets/milestones"; import { RecalcAllScores } from "utils/calculations/recalc-scores"; import { UpdateGameSongIDCounter } from "utils/db"; import { InitaliseFolderChartLookup } from "utils/folder"; import { ArrayDiff, IsSupported, WrapScriptPromise } from "utils/misc"; import path from "path"; import type { KtLogger } from "lib/logger/logger"; import type { BulkWriteOperation, DeleteWriteOpResultObject } from "mongodb"; import type { ICollection } from "monk"; import type { BMSCourseDocument, ChartDocument, FolderDocument, Game, GoalDocument, MilestoneDocument, MilestoneSetDocument, SongDocument, TableDocument, } from "tachi-common"; interface SyncInstructions { pattern: RegExp; handler: ( c: Array, collection: ICollection, logger: KtLogger, collectionName: string ) => Promise; } async function RemoveNotPresent>( documents: Array, collection: ICollection, field: keyof T, logger: KtLogger ) { logger.verbose(`Removing all documents that are no longer present.`); // Remove anything no longer present. // Note that $nin is incredibly slow. // @ts-expect-error generic system failing const r = (await collection.remove({ [field]: { $nin: documents.map((e) => e[field] as unknown) }, })) as DeleteWriteOpResultObject; if (r.deletedCount !== undefined && r.deletedCount > 0) { logger.info(`Removed ${r.deletedCount} documents.`); } } async function GenericUpsert>( documents: Array, collection: ICollection, field: keyof T, logger: KtLogger, remove = false, update = true ) { logger.verbose(`Running bulkwrite.`); const bwriteOps: Array> = []; const allExistingDocs = await collection.find({}); const map = new Map(); for (const doc of allExistingDocs) { map.set(doc[field], doc); } const changedFields = []; let i = 0; for (const document of documents) { i++; if (i % 10_000 === 0) { logger.verbose(`On document ${i}/${documents.length}.`); } const exists = map.get(document[field]); if (exists === undefined) { bwriteOps.push({ // @ts-expect-error Actually, T is assignable to OptionalId. insertOne: { document }, }); } else if (update && fjsh.hash(document, "sha256") !== fjsh.hash(exists, "sha256")) { bwriteOps.push({ replaceOne: { // @ts-expect-error Known X->Y generic issue. filter: { [field]: document[field], }, replacement: document, }, }); changedFields.push(field); } // free some memory. map.delete(document[field]); } if (bwriteOps.length === 0) { logger.verbose(`No differences. Not performing any update.`); } else { const { deletedCount, insertedCount, matchedCount, upsertedCount, modifiedCount } = await collection.bulkWrite(bwriteOps); logger.info(`Performed bulkWrite.`, { deletedCount, insertedCount, matchedCount, upsertedCount, modifiedCount, }); } if (remove) { await RemoveNotPresent(documents, collection, field, logger); } return { thingsChanged: bwriteOps.length, changedFields, }; } const syncInstructions: Array = [ { pattern: /^charts-(b|p)ms/u, handler: async ( charts: Array, collection: ICollection, logger, collectionName ) => { const r = await GenericUpsert(charts, collection, "chartID", logger, false); if (r.thingsChanged) { await InitaliseFolderChartLookup(); await UpdateIsPrimaryStatus(); await UpdateGameSongIDCounter(collectionName.includes("bms") ? "bms" : "pms"); await RecalcAllScores({ chartID: { $in: r.changedFields }, }); } }, }, { pattern: /^charts-/u, handler: async ( charts: Array, collection: ICollection, logger ) => { const r = await GenericUpsert(charts, collection, "chartID", logger, true); if (r.thingsChanged) { await InitaliseFolderChartLookup(); await UpdateIsPrimaryStatus(); await RecalcAllScores({ chartID: { $in: r.changedFields }, }); } }, }, { pattern: /^songs-(b|p)ms/u, handler: async ( songs: Array, collection: ICollection, logger ) => { const r = await GenericUpsert(songs, collection, "id", logger, false); if (r.thingsChanged) { await RecalcAllScores({ songID: { $in: r.changedFields }, }); } }, }, { pattern: /^songs-/u, handler: async ( songs: Array, collection: ICollection, logger ) => { const r = await GenericUpsert(songs, collection, "id", logger, true); if (r.thingsChanged) { await RecalcAllScores({ songID: { $in: r.changedFields }, }); } }, }, { pattern: /^folders/u, handler: async ( folders: Array, collection: ICollection, logger ) => { const r = await GenericUpsert( folders.filter((e) => TachiConfig.GAMES.includes(e.game)), collection, "folderID", logger, true ); if (r.thingsChanged) { await InitaliseFolderChartLookup(); const allModifiedFolderIDs = r.changedFields as Array; const keptFolderIDs = await db.folders.find( { folderID: { $in: allModifiedFolderIDs }, }, { projection: { folderID: 1 } } ); // Find out what folders have been removed by diffing the set of all // modified folders against all that are still present. const removedFolderIDs = ArrayDiff( allModifiedFolderIDs, keptFolderIDs.map((e) => e.folderID) ); await RemoveStaleFolderShowcaseStats(removedFolderIDs); } }, }, { pattern: /^tables/u, handler: (tables: Array, collection: ICollection, logger) => GenericUpsert( tables.filter((e) => TachiConfig.GAMES.includes(e.game)), collection, "tableID", logger, true ), }, { pattern: /^bms-course-lookup/u, handler: async ( bmsCourseDocuments: Array, collection: ICollection, logger ) => { if (TachiConfig.TYPE === "ktchi") { return; } await GenericUpsert(bmsCourseDocuments, collection, "md5sums", logger); }, }, { pattern: /^goals/u, handler: async ( goals: Array, collection: ICollection, logger ) => { // never remove goals. Never update goals either. Only insert new ones as // they come in. await GenericUpsert( goals.filter((e) => IsSupported(e.game)), collection, "goalID", logger, false, false ); }, }, { pattern: /^milestone-sets/u, handler: async ( milestoneSets: Array, collection: ICollection, logger ) => { // removing and updating these is fine. Users cannot subscibe to sets. await GenericUpsert( milestoneSets.filter((e) => IsSupported(e.game)), collection, "setID", logger ); }, }, { pattern: /^milestones/u, handler: async ( milestones: Array, collection: ICollection, logger ) => { const r = await GenericUpsert( milestones.filter((e) => IsSupported(e.game)), collection, "milestoneID", logger, true ); if (r.thingsChanged) { const affectedMilestoneIDs = r.changedFields as Array; await Promise.all(affectedMilestoneIDs.map((e) => UpdateMilestoneSubscriptions(e))); } }, }, ]; const logger = CreateLogCtx("Database Sync"); const program = new Command(); program.option("-l, --localPath "); program.parse(process.argv); const options: { localPath: string | undefined; } = program.opts(); async function SynchroniseDBWithSeeds() { // Wait for mongo to connect first. await monkDB.then(() => void 0); const databaseSeedsRepo = await PullDatabaseSeeds(options.localPath); for await (const { collectionName, data } of databaseSeedsRepo.IterateCollections()) { const spawnLogger = CreateLogCtx(`${collectionName} Sync`); if (collectionName.startsWith("songs-") || collectionName.startsWith("charts-")) { const game = collectionName.split("-")[1]; if (!TachiConfig.GAMES.includes(game as Game)) { spawnLogger.verbose( `Skipping ${collectionName} (${game}) as it isn't for ${TachiConfig.NAME}.` ); continue; } } spawnLogger.verbose(`Found ${data.length} documents.`); let matchedSomething = false; for (const syncInst of syncInstructions) { if (syncInst.pattern.exec(collectionName)) { spawnLogger.info(`Starting handler...`); await syncInst.handler( data, monkDB.get(collectionName), spawnLogger, collectionName ); matchedSomething = true; break; } } if (!matchedSomething) { spawnLogger.warn( `Collection ${collectionName} didn't match any sync instructions. Skipping.` ); } } logger.info(`Done.`); await databaseSeedsRepo.Destroy(); } if (require.main === module) { WrapScriptPromise(SynchroniseDBWithSeeds(), logger); }