Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883#!/usr/bin/env node// sync-atproto.mjs// Master ATProto synchronization script// Handles all content types: paintings, moods, pieces, kidlisp// // Features:// 1. Cleans up duplicate ATProto records (keeps earliest by ref)// 2. Syncs MongoDB → ATProto for missing records// 3. Never creates duplicates// 4. Can be run multiple times safely// 5. Handles anonymous content → art account// 6. Handles user content → personal accounts
import { connect } from "../../system/backend/database.mjs";import { AtpAgent } from "@atproto/api";import { MediaTypes, createMediaRecord, deleteMediaRecord, getCollection, supportsAnonymous } from "../../system/backend/media-atproto.mjs";
const PDS_URL = process.env.PDS_URL || "https://at.aesthetic.computer";
// Collection configurations - now using unified media-atproto moduleconst COLLECTIONS = { paintings: { mediaType: MediaTypes.PAINTING, mongoCollection: "paintings", atprotoCollection: getCollection(MediaTypes.PAINTING), hasAnonymous: supportsAnonymous(MediaTypes.PAINTING) }, moods: { mediaType: MediaTypes.MOOD, mongoCollection: "moods", atprotoCollection: getCollection(MediaTypes.MOOD), hasAnonymous: supportsAnonymous(MediaTypes.MOOD) }, pieces: { mediaType: MediaTypes.PIECE, mongoCollection: "pieces", atprotoCollection: getCollection(MediaTypes.PIECE), hasAnonymous: supportsAnonymous(MediaTypes.PIECE) }, kidlisp: { mediaType: MediaTypes.KIDLISP, mongoCollection: "kidlisp", atprotoCollection: getCollection(MediaTypes.KIDLISP), hasAnonymous: supportsAnonymous(MediaTypes.KIDLISP) }, tapes: { mediaType: MediaTypes.TAPE, mongoCollection: "tapes", atprotoCollection: getCollection(MediaTypes.TAPE), hasAnonymous: supportsAnonymous(MediaTypes.TAPE) }};
/** * Wipe all ATProto data for a specific user (including anonymous/art-guest) * - Deletes all ATProto records * - Clears atproto.rkey fields from MongoDB * - Supports art.at.aesthetic.computer for anonymous content */async function wipeUserAtprotoData(userHandle, options = {}) { const { dryRun = true, contentTypes = ['paintings', 'moods', 'pieces', 'kidlisp', 'tapes'] } = options; console.log(`\n🗑️ WIPE MODE: ${userHandle}`); console.log(` Mode: ${dryRun ? '🔍 DRY RUN (no changes)' : '⚡ LIVE (deleting)'}`); console.log(` Content types: ${contentTypes.join(', ')}\n`); const database = await connect(); const users = database.db.collection("users"); // Find user by handle const user = await users.findOne({ "atproto.handle": userHandle }); if (!user) { console.error(`❌ User not found: ${userHandle}`); await database.disconnect(); return; } if (!user.atproto?.did || !user.atproto?.password) { console.error(`❌ User has no ATProto account: ${userHandle}`); await database.disconnect(); return; } console.log(`📋 Found user: ${user.atproto.handle} (${user.atproto.did})`); // Login to ATProto const agent = new AtpAgent({ service: PDS_URL }); await agent.login({ identifier: user.atproto.did, password: user.atproto.password }); const stats = { deleted: 0, mongoCleared: 0, errors: [] }; // Process each requested content type for (const [contentType, config] of Object.entries(COLLECTIONS)) { if (!contentTypes.includes(contentType)) { continue; // Skip this type } console.log(`\n Processing ${contentType}...`); // 1. Fetch all ATProto records for this collection let atprotoRecords = []; let cursor = undefined; while (true) { const response = await agent.com.atproto.repo.listRecords({ repo: user.atproto.did, collection: config.atprotoCollection, limit: 100, cursor }); atprotoRecords.push(...response.data.records); if (!response.data.cursor) break; cursor = response.data.cursor; } console.log(` Found ${atprotoRecords.length} ATProto records`); // 2. Delete all ATProto records if (atprotoRecords.length > 0) { for (const record of atprotoRecords) { const rkey = record.uri.split("/").pop(); if (!dryRun) { try { await agent.com.atproto.repo.deleteRecord({ repo: user.atproto.did, collection: config.atprotoCollection, rkey }); stats.deleted++; } catch (error) { console.log(` ❌ Failed to delete ${rkey}: ${error.message}`); stats.errors.push(`Failed to delete ${contentType}/${rkey}: ${error.message}`); } } else { stats.deleted++; } } console.log(` ${dryRun ? 'Would delete' : 'Deleted'} ${atprotoRecords.length} records from ATProto`); } // 3. Clear atproto.rkey fields from MongoDB const collection = database.db.collection(config.mongoCollection); // For anonymous content (art-guest), match records WITHOUT user field // For user content, match specific user._id const isAnonymous = user._id === "art-guest"; const mongoQuery = isAnonymous ? { user: { $exists: false }, "atproto.rkey": { $exists: true } } : { user: user._id, "atproto.rkey": { $exists: true } }; const mongoCount = await collection.countDocuments(mongoQuery); if (mongoCount > 0) { if (!dryRun) { const result = await collection.updateMany( mongoQuery, { $unset: { "atproto.rkey": "" } } ); stats.mongoCleared += result.modifiedCount; } else { stats.mongoCleared += mongoCount; } console.log(` ${dryRun ? 'Would clear' : 'Cleared'} atproto.rkey from ${mongoCount} MongoDB records`); } } // Summary console.log(`\n✨ WIPE SUMMARY:`); console.log(` ATProto records deleted: ${stats.deleted}`); console.log(` MongoDB rkeys cleared: ${stats.mongoCleared}`); if (stats.errors.length > 0) { console.log(` Errors: ${stats.errors.length}`); stats.errors.forEach(err => console.log(` ${err}`)); } if (dryRun) { console.log(`\n💡 Run with 'live' argument to apply changes`); } await database.disconnect();}
/** * Restore ATProto data for a specific user from MongoDB (including anonymous/art-guest) * - Creates ATProto records for all MongoDB items that don't have them * - Updates MongoDB with rkeys * - Supports art.at.aesthetic.computer for anonymous content */async function restoreUserAtprotoData(userHandle, options = {}) { const { dryRun = true, contentTypes = ['paintings', 'moods', 'pieces', 'kidlisp', 'tapes'] } = options; console.log(`\n♻️ RESTORE MODE: ${userHandle}`); console.log(` Mode: ${dryRun ? '🔍 DRY RUN (no changes)' : '⚡ LIVE (creating)'}`); console.log(` Content types: ${contentTypes.join(', ')}\n`); const database = await connect(); const users = database.db.collection("users"); // Find user by handle const user = await users.findOne({ "atproto.handle": userHandle }); if (!user) { console.error(`❌ User not found: ${userHandle}`); await database.disconnect(); return; } if (!user.atproto?.did || !user.atproto?.password) { console.error(`❌ User has no ATProto account: ${userHandle}`); await database.disconnect(); return; } console.log(`📋 Found user: ${user.atproto.handle} (${user.atproto.did})`); const allStats = []; // Process each requested content type for (const contentType of contentTypes) { if (!COLLECTIONS[contentType]) { console.log(` ⚠️ Unknown content type: ${contentType}, skipping`); continue; } console.log(`\n Syncing ${contentType}...`); const stats = await syncContentForUser(contentType, user, { dryRun, verbose: false }); allStats.push(stats); console.log(` MongoDB: ${stats.mongoTotal}, ATProto: ${stats.atprotoTotal}`); console.log(` Duplicates cleaned: ${stats.duplicatesDeleted}`); console.log(` Missing blobs fixed: ${stats.blobsFixed}`); console.log(` Records ${dryRun ? 'to create' : 'created'}: ${stats.created}`); if (stats.errors.length > 0) { console.log(` ❌ Errors: ${stats.errors.length}`); stats.errors.slice(0, 3).forEach(err => console.log(` ${err}`)); } } // Summary const totalCreated = allStats.reduce((sum, s) => sum + s.created, 0); const totalDuplicates = allStats.reduce((sum, s) => sum + s.duplicatesDeleted, 0); const totalBlobsFixed = allStats.reduce((sum, s) => sum + s.blobsFixed, 0); const totalErrors = allStats.reduce((sum, s) => sum + s.errors.length, 0); console.log(`\n✨ RESTORE SUMMARY:`); console.log(` Records ${dryRun ? 'to create' : 'created'}: ${totalCreated}`); console.log(` Duplicates cleaned: ${totalDuplicates}`); console.log(` Missing blobs fixed: ${totalBlobsFixed}`); if (totalErrors > 0) { console.log(` Errors: ${totalErrors}`); } if (dryRun) { console.log(`\n💡 Run with 'live' argument to apply changes`); } await database.disconnect();}
/** * Sync a specific content type for a user */async function syncContentForUser(contentType, user, options = {}) { const { dryRun = true, verbose = false } = options; const config = COLLECTIONS[contentType]; const isAnonymous = user._id === "art-guest"; const stats = { contentType, handle: user.atproto.handle, mongoTotal: 0, atprotoTotal: 0, duplicatesFound: 0, duplicatesDeleted: 0, missingBlobs: 0, blobsFixed: 0, missingInAtproto: 0, created: 0, errors: [] }; try { const database = await connect(); const collection = database.db.collection(config.mongoCollection); // Login to ATProto const agent = new AtpAgent({ service: PDS_URL }); await agent.login({ identifier: user.atproto.did, password: user.atproto.password }); // 1. Fetch all MongoDB items for this user // Anonymous content: no user field. User content: match user._id const mongoQuery = isAnonymous ? { user: { $exists: false } } : { user: user._id }; const mongoItems = await collection.find(mongoQuery).sort({ when: 1 }).toArray(); stats.mongoTotal = mongoItems.length; // 2. Fetch all ATProto records let atprotoRecords = []; let cursor = undefined; while (true) { const response = await agent.com.atproto.repo.listRecords({ repo: user.atproto.did, collection: config.atprotoCollection, limit: 100, cursor }); atprotoRecords.push(...response.data.records); if (!response.data.cursor) break; cursor = response.data.cursor; } stats.atprotoTotal = atprotoRecords.length; // 3. Build a map of ATProto records by ref (MongoDB _id) const atprotoByRef = new Map(); const atprotoByRkey = new Map(); const duplicateRefs = new Set(); const recordsWithoutBlobs = []; for (const record of atprotoRecords) { const ref = record.value.ref; const rkey = record.uri.split("/").pop(); atprotoByRkey.set(rkey, record); // Check if record has required blobs (thumbnail for paintings/moods, video for tapes) // Note: thumbnail is optional in lexicon, but we want to ensure it exists if the painting exists // The @atproto/api library deserializes blobs, so thumbnail.ref will be a CID object, not {$link: "..."} if (config.mediaType === MediaTypes.PAINTING || config.mediaType === MediaTypes.MOOD) { const hasThumbnail = record.value.thumbnail && record.value.thumbnail.ref && typeof record.value.thumbnail.ref !== 'undefined'; // Only flag as missing blob if the MongoDB record exists // We'll validate this after fetching MongoDB records if (!hasThumbnail && ref) { recordsWithoutBlobs.push({ record, rkey, ref }); } } // Check for missing video blob on tapes if (config.mediaType === MediaTypes.TAPE) { const hasVideo = record.value.video && record.value.video.ref && typeof record.value.video.ref !== 'undefined'; if (!hasVideo && ref) { recordsWithoutBlobs.push({ record, rkey, ref }); } } if (ref) { if (atprotoByRef.has(ref)) { // Found a duplicate! duplicateRefs.add(ref); stats.duplicatesFound++; } else { atprotoByRef.set(ref, record); } } } // 4. Delete duplicates (keep only the first one for each ref) if (duplicateRefs.size > 0) { for (const ref of duplicateRefs) { // Find all records with this ref const dupes = atprotoRecords.filter(r => r.value.ref === ref); // Sort by creation time (earliest first) and keep the first one dupes.sort((a, b) => new Date(a.value.when) - new Date(b.value.when)); const toDelete = dupes.slice(1); // Delete all but the first // Delete in batches of 50 const DELETE_BATCH_SIZE = 50; for (let i = 0; i < toDelete.length; i += DELETE_BATCH_SIZE) { const batch = toDelete.slice(i, i + DELETE_BATCH_SIZE); if (!dryRun) { await Promise.all(batch.map(async (dupe) => { const rkey = dupe.uri.split("/").pop(); try { await agent.com.atproto.repo.deleteRecord({ repo: user.atproto.did, collection: config.atprotoCollection, rkey }); stats.duplicatesDeleted++; } catch (error) { stats.errors.push(`${user.atproto?.handle || 'anonymous'}: Failed to delete duplicate ${rkey}: ${error.message}`); } })); } else { stats.duplicatesDeleted += batch.length; } } } } // 5. Fix records with missing blobs (delete and mark for recreation) // First, validate which records actually exist in MongoDB const { ObjectId } = await import('mongodb'); const validRecordsWithoutBlobs = []; for (const item of recordsWithoutBlobs) { try { const painting = await collection.findOne({ _id: new ObjectId(item.ref) }); if (painting) { validRecordsWithoutBlobs.push(item); } } catch (error) { // ref might not be a valid ObjectId - skip it } } stats.missingBlobs = validRecordsWithoutBlobs.length; if (validRecordsWithoutBlobs.length > 0) { if (verbose) { console.log(` Found ${validRecordsWithoutBlobs.length} records with missing blobs`); } // Delete the incomplete ATProto records const DELETE_BATCH_SIZE = 50; for (let i = 0; i < validRecordsWithoutBlobs.length; i += DELETE_BATCH_SIZE) { const batch = validRecordsWithoutBlobs.slice(i, i + DELETE_BATCH_SIZE); if (!dryRun) { await Promise.all(batch.map(async ({ rkey, ref }) => { try { await agent.com.atproto.repo.deleteRecord({ repo: user.atproto.did, collection: config.atprotoCollection, rkey }); // Remove from atprotoByRef map so it will be recreated if (ref) { atprotoByRef.delete(ref); } // Clear the rkey from MongoDB so it will be updated try { await collection.updateOne( { _id: new ObjectId(ref) }, { $unset: { "atproto.rkey": "" } } ); } catch (mongoError) { // ref might not be a valid ObjectId or record doesn't exist } stats.blobsFixed++; } catch (error) { stats.errors.push(`${user.atproto?.handle || 'anonymous'}: Failed to delete incomplete record ${rkey}: ${error.message}`); } })); } else { stats.blobsFixed += batch.length; // In dry run, also remove from map to show what would be recreated batch.forEach(({ ref }) => { if (ref) atprotoByRef.delete(ref); }); } } } // 6. Find MongoDB items that don't exist in ATProto const missingInAtproto = []; for (const item of mongoItems) { const refId = item._id.toString(); // Check if this item exists in ATProto by ref if (!atprotoByRef.has(refId)) { // Check if MongoDB has an atproto.rkey that actually exists if (item.atproto?.rkey && atprotoByRkey.has(item.atproto.rkey)) { // The rkey exists, record is there but maybe needs ref update continue; } missingInAtproto.push(item); stats.missingInAtproto++; } } // 7. Create missing items in ATProto (includes items with deleted incomplete records) if (missingInAtproto.length > 0) { const mongoUpdates = []; // Process in batches of 10 concurrent creates (reduced to avoid rate limiting) const BATCH_SIZE = 10; for (let i = 0; i < missingInAtproto.length; i += BATCH_SIZE) { const batch = missingInAtproto.slice(i, i + BATCH_SIZE); if (!dryRun) { await Promise.all(batch.map(async (item, idx) => { const itemName = item.slug || item.text?.substring(0, 30) || item.code || item._id.toString(); try { // Use unified media-atproto module const result = await createMediaRecord( database, config.mediaType, item, { userSub: isAnonymous ? null : user._id } ); if (result.error) { throw new Error(result.error); } stats.created++; // Queue MongoDB update if (result.rkey) { mongoUpdates.push({ updateOne: { filter: { _id: item._id }, update: { $set: { "atproto.rkey": result.rkey } } } }); } } catch (error) { stats.errors.push(`${user.atproto?.handle || 'anonymous'}: Failed to create ${itemName}: ${error.message}`); } })); } else { stats.created += batch.length; } } // Batch update MongoDB if (mongoUpdates.length > 0 && !dryRun) { try { await collection.bulkWrite(mongoUpdates); } catch (error) { stats.errors.push(`${user.atproto?.handle || 'anonymous'}: Failed to bulk update MongoDB: ${error.message}`); } } } await database.disconnect(); } catch (error) { stats.errors.push(`Fatal error: ${error.message}`); } return stats;}
/** * Sync all content types */async function syncAll(options = {}) { const { dryRun = true, verbose = false, userFilter = null, contentTypes = ['paintings', 'moods', 'pieces', 'kidlisp'] } = options; console.log(`\n🔄 ATPROTO MASTER SYNC SCRIPT`); console.log(` Mode: ${dryRun ? '🔍 DRY RUN (no changes)' : '⚡ LIVE (making changes)'}`); console.log(` Content types: ${contentTypes.join(', ')}`); console.log(` Time: ${new Date().toISOString()}\n`); const database = await connect(); const allStats = []; // STEP 1: Sync anonymous content to art account const artUser = await database.db.collection("users").findOne({ _id: "art-guest" }); if (!artUser) { console.error(`\n⚠️ Art account (art-guest) not found in MongoDB!`); console.error(` Run: cd at && node scripts/setup-art-account.mjs\n`); } else { console.log(`\n🎨 Step 1: Syncing anonymous content to ${artUser.atproto.handle}...`); for (const contentType of contentTypes) { const config = COLLECTIONS[contentType]; if (!config.hasAnonymous) { if (verbose) console.log(` Skipping ${contentType} (no anonymous content)`); continue; } // Create a pseudo-user object for anonymous content const anonymousUser = { _id: null, // null user means anonymous atproto: artUser.atproto }; const stats = await syncContentForUser(contentType, anonymousUser, { dryRun, verbose }); allStats.push(stats); console.log(` ${contentType.padEnd(10)}: ${stats.mongoTotal} in MongoDB, ${stats.atprotoTotal} in ATProto, ${stats.duplicatesDeleted} dupes, ${stats.created} to create`); if (stats.errors.length > 0) { console.log(` ${''.padEnd(10)} ⚠️ ${stats.errors.length} errors`); } } } // STEP 2: Sync user content if (anonymousOnly) { console.log(`\n👥 Step 2: Skipping user content (--anonymous-only flag)\n`); } else { console.log(`\n👥 Step 2: Syncing user content...`); const query = { "atproto.did": { $exists: true }, _id: { $ne: "art-guest" } }; if (userFilter) { query["atproto.handle"] = new RegExp(userFilter, "i"); } const users = await database.db.collection("users").find(query).toArray(); console.log(`📋 Found ${users.length} users with ATProto accounts\n`); // Process users one at a time to avoid ATProto PDS rate limiting const USER_BATCH_SIZE = 1; let completedUsers = 0; const startTime = Date.now(); for (let i = 0; i < users.length; i += USER_BATCH_SIZE) { const userBatch = users.slice(i, i + USER_BATCH_SIZE); const batchStart = Date.now(); // Track batch stats let batchCreated = 0; let batchDuplicates = 0; let batchErrors = 0; await Promise.all(userBatch.map(async (user) => { const userStats = []; for (const contentType of contentTypes) { const stats = await syncContentForUser(contentType, user, { dryRun, verbose }); allStats.push(stats); userStats.push(stats); // Aggregate batch stats batchCreated += stats.created; batchDuplicates += stats.duplicatesDeleted; batchErrors += stats.errors.length; } // Show summary for this user inline only if there's activity const userIssues = userStats.filter(s => s.duplicatesDeleted > 0 || s.created > 0 || s.errors.length > 0); completedUsers++; if (userIssues.length > 0) { const summary = userIssues.map(s => { const parts = []; if (s.duplicatesDeleted > 0) parts.push(`-${s.duplicatesDeleted}`); if (s.created > 0) parts.push(`+${s.created}`); if (s.errors.length > 0) parts.push(`❌${s.errors.length}`); return `${s.contentType}:${parts.join(',')}`; }).join(' '); console.log(` ${user.atproto.handle.padEnd(40)} ${summary}`); } })); // Batch summary const batchTime = ((Date.now() - batchStart) / 1000).toFixed(1); const totalTime = ((Date.now() - startTime) / 1000).toFixed(0); const usersPerSec = (completedUsers / (Date.now() - startTime) * 1000).toFixed(1); const eta = users.length > completedUsers ? ((users.length - completedUsers) / usersPerSec).toFixed(0) + 's' : 'done'; console.log(` 📊 Batch ${Math.floor(i / USER_BATCH_SIZE) + 1}: ${userBatch.length} users in ${batchTime}s | Progress: ${completedUsers}/${users.length} (${usersPerSec}/s) | Created: ${batchCreated} | Dupes: ${batchDuplicates} | Errors: ${batchErrors} | ETA: ${eta}`); } console.log('\n'); } // End of if (!anonymousOnly) block // STEP 3: Print summary console.log(`\n📊 SUMMARY BY CONTENT TYPE`); for (const contentType of contentTypes) { const typeStats = allStats.filter(s => s.contentType === contentType); if (typeStats.length === 0) continue; const totalMongo = typeStats.reduce((sum, s) => sum + s.mongoTotal, 0); const totalAtproto = typeStats.reduce((sum, s) => sum + s.atprotoTotal, 0); const totalDuplicates = typeStats.reduce((sum, s) => sum + s.duplicatesDeleted, 0); const totalMissingBlobs = typeStats.reduce((sum, s) => sum + s.missingBlobs, 0); const totalBlobsFixed = typeStats.reduce((sum, s) => sum + s.blobsFixed, 0); const totalCreated = typeStats.reduce((sum, s) => sum + s.created, 0); const totalErrors = typeStats.reduce((sum, s) => sum + s.errors.length, 0); console.log(`\n${contentType.toUpperCase()}:`); console.log(` MongoDB records: ${totalMongo}`); console.log(` ATProto records: ${totalAtproto}`); console.log(` Duplicates to delete: ${totalDuplicates}`); console.log(` Missing blobs to fix: ${totalMissingBlobs} (will delete & recreate: ${totalBlobsFixed})`); console.log(` Records to create: ${totalCreated}`); if (totalErrors > 0) { console.log(` Errors: ${totalErrors}`); } // Show top users with issues const usersWithIssues = typeStats.filter(s => s.duplicatesDeleted > 0 || s.created > 0); if (usersWithIssues.length > 0 && verbose) { console.log(` Users with issues: ${usersWithIssues.length}`); usersWithIssues.slice(0, 5).forEach(s => { console.log(` ${s.handle.padEnd(45)} dupes:${s.duplicatesDeleted} missing:${s.created}`); }); } } // Overall summary const totalDuplicates = allStats.reduce((sum, s) => sum + s.duplicatesDeleted, 0); const totalCreated = allStats.reduce((sum, s) => sum + s.created, 0); const totalErrors = allStats.reduce((sum, s) => sum + s.errors.length, 0); console.log(`\n✨ OVERALL:`); console.log(` Total duplicates to delete: ${totalDuplicates}`); console.log(` Total records to create: ${totalCreated}`); if (totalErrors > 0) { console.log(` Total errors: ${totalErrors}`); // Group errors by type const allErrors = allStats.flatMap(s => s.errors); const errorGroups = {}; for (const error of allErrors) { // Extract error type from message const match = error.match(/: (.+?):/); const type = match ? match[1] : 'Unknown'; errorGroups[type] = (errorGroups[type] || 0) + 1; } console.log(`\n Error breakdown:`); Object.entries(errorGroups).forEach(([type, count]) => { console.log(` ${type}: ${count}`); }); // Show sample errors console.log(`\n Sample errors:`); allErrors.slice(0, 10).forEach(err => { console.log(` ${err}`); }); } if (dryRun) { console.log(`\n💡 Run with 'live' argument to apply changes`); } await database.disconnect();}
// Parse command line argumentsconst args = process.argv.slice(2);const mode = args.find(a => a === 'live' || a === 'dry-run') || 'dry-run';const verbose = args.includes('--verbose') || args.includes('-v');const userFilter = args.find(a => a.startsWith('--user='))?.split('=')[1];const wipeUser = args.find(a => a.startsWith('--wipe='))?.split('=')[1];const restoreUser = args.find(a => a.startsWith('--restore='))?.split('=')[1];const anonymousOnly = args.includes('--anonymous-only');
// Parse content typeslet contentTypes = ['paintings', 'moods', 'pieces', 'kidlisp', 'tapes'];const contentTypeArg = args.find(a => a.startsWith('--types='));if (contentTypeArg) { contentTypes = contentTypeArg.split('=')[1].split(',');}
// Single type shortcutsif (args.includes('--paintings-only')) contentTypes = ['paintings'];if (args.includes('--moods-only')) contentTypes = ['moods'];if (args.includes('--pieces-only')) contentTypes = ['pieces'];if (args.includes('--kidlisp-only')) contentTypes = ['kidlisp'];if (args.includes('--tapes-only')) contentTypes = ['tapes'];
const dryRun = mode !== 'live';
// Show usage if --helpif (args.includes('--help') || args.includes('-h')) { console.log(`🔄 ATProto Master Sync Script
Usage: node sync-atproto.mjs [mode] [options]
Modes: dry-run Show what would be changed (default) live Actually make changes
Options: --verbose, -v Show detailed progress --user=PATTERN Only sync users matching pattern --anonymous-only Only sync anonymous content (skip all users) --wipe=HANDLE Wipe all ATProto data for user (e.g., --wipe=jeffrey.at.aesthetic.computer) --restore=HANDLE Restore ATProto data for user from MongoDB (e.g., --restore=jeffrey.at.aesthetic.computer) --types=TYPE1,TYPE2 Only sync specific types (paintings,moods,pieces,kidlisp,tapes) --paintings-only Only sync paintings --moods-only Only sync moods --pieces-only Only sync pieces --kidlisp-only Only sync kidlisp --tapes-only Only sync tapes --help, -h Show this help
Examples: node sync-atproto.mjs # Dry run all content types node sync-atproto.mjs live # Sync all content types node sync-atproto.mjs live --paintings-only # Only sync paintings node sync-atproto.mjs live --anonymous-only --tapes-only # Only sync anonymous tapes node sync-atproto.mjs live --user=jeffrey # Only sync jeffrey's content node sync-atproto.mjs live --types=paintings,moods # Only paintings and moods node sync-atproto.mjs dry-run --verbose # See detailed dry run # Wipe examples node sync-atproto.mjs --wipe=jeffrey.at.aesthetic.computer # Dry run wipe node sync-atproto.mjs live --wipe=jeffrey.at.aesthetic.computer # Wipe jeffrey's ATProto data # Restore examples node sync-atproto.mjs --restore=jeffrey.at.aesthetic.computer # Dry run restore node sync-atproto.mjs live --restore=jeffrey.at.aesthetic.computer # Restore all content node sync-atproto.mjs live --restore=jeffrey.at.aesthetic.computer --paintings-only # Only restore paintings node sync-atproto.mjs live --restore=jeffrey.at.aesthetic.computer --types=paintings,moods # Only paintings and moods`); process.exit(0);}
// Handle wipe modeif (wipeUser) { wipeUserAtprotoData(wipeUser, { dryRun, contentTypes }) .then(() => { console.log(`\n✅ Done!\n`); process.exit(0); }) .catch(error => { console.error('\n❌ Fatal error:', error); process.exit(1); });} else if (restoreUser) { // Handle restore mode restoreUserAtprotoData(restoreUser, { dryRun, contentTypes }) .then(() => { console.log(`\n✅ Done!\n`); process.exit(0); }) .catch(error => { console.error('\n❌ Fatal error:', error); process.exit(1); });} else { syncAll({ dryRun, verbose, userFilter, contentTypes }) .then(() => { console.log(`\n✅ Done!\n`); process.exit(0); }) .catch(error => { console.error('\n❌ Fatal error:', error); process.exit(1); });}