Monorepo for Aesthetic.Computer aesthetic.computer
at main 341 lines 10 kB view raw
1// Database, 23.07.09.17.33 2// Manages database connections to MongoDB. 3// And has application-specific queries. 4 5import { MongoClient, ObjectId, ServerApiVersion } from "mongodb"; 6 7let client; 8let clientPromise; // For connection reuse in serverless 9 10async function connect() { 11 // Read environment variables at runtime, not at module load time 12 const mongoDBConnectionString = process.env.MONGODB_CONNECTION_STRING; 13 const mongoDBName = process.env.MONGODB_NAME; 14 15 // Validate environment variables 16 if (!mongoDBConnectionString) { 17 throw new Error('MONGODB_CONNECTION_STRING environment variable is not set'); 18 } 19 if (!mongoDBName) { 20 throw new Error('MONGODB_NAME environment variable is not set'); 21 } 22 23 // Reuse existing connection if available (critical for serverless) 24 if (client) { 25 try { 26 // Verify connection is still alive 27 await client.db().admin().ping(); 28 console.log(`♻️ Reusing existing MongoDB connection`); 29 return { db: client.db(mongoDBName), disconnect }; 30 } catch (e) { 31 // Connection dead, clear it 32 console.log(`⚠️ Existing connection dead, reconnecting...`); 33 client = null; 34 clientPromise = null; 35 } 36 } 37 38 // If a connection is in progress, wait for it 39 if (clientPromise) { 40 try { 41 client = await clientPromise; 42 return { db: client.db(mongoDBName), disconnect }; 43 } catch (e) { 44 clientPromise = null; 45 } 46 } 47 48 // Try multiple connection strategies 49 // TLS is controlled by the connection string (mongodb+srv:// defaults to TLS, 50 // mongodb:// defaults to no TLS). Don't force tls: true here. 51 const strategies = [ 52 { 53 name: 'standard', 54 options: { 55 serverSelectionTimeoutMS: 10000, 56 connectTimeoutMS: 10000, 57 socketTimeoutMS: 45000, 58 maxPoolSize: 10, 59 minPoolSize: 1, 60 } 61 }, 62 { 63 name: 'direct', 64 options: { 65 directConnection: true, 66 serverSelectionTimeoutMS: 5000, 67 connectTimeoutMS: 5000, 68 socketTimeoutMS: 30000, 69 maxPoolSize: 10, 70 minPoolSize: 1, 71 } 72 }, 73 { 74 name: 'minimal', 75 options: { 76 serverSelectionTimeoutMS: 5000, 77 connectTimeoutMS: 5000, 78 maxPoolSize: 10, 79 minPoolSize: 1, 80 } 81 } 82 ]; 83 84 let lastError; 85 86 for (const strategy of strategies) { 87 try { 88 const newClient = new MongoClient(mongoDBConnectionString, strategy.options); 89 clientPromise = newClient.connect(); 90 client = await clientPromise; 91 92 console.log(`✅ MongoDB connected successfully (${strategy.name})`); 93 const db = client.db(mongoDBName); 94 return { db, disconnect }; 95 } catch (error) { 96 console.log(`❌ Connection failed (${strategy.name}):`, error.message); 97 lastError = error; 98 clientPromise = null; 99 if (client) { 100 try { await client.close(); } catch (e) { /* ignore */ } 101 client = null; 102 } 103 } 104 } 105 106 // All strategies failed 107 throw lastError; 108} 109 110async function disconnect() { 111 // In serverless, don't actually close - let connection be reused 112 // The connection will be cleaned up when the Lambda container is recycled 113 // if (client) await client.close?.(); 114} 115 116// Re-usable calls to the application. 117async function moodFor(sub, database) { 118 const collection = database.db.collection("moods"); 119 const record = ( 120 await collection.find({ user: sub }).sort({ when: -1 }).limit(1).toArray() 121 )[0]; // Most recent mood or `undefined` if none are found. 122 if (record?.mood) { 123 record.mood = record.mood.replaceAll("\\n", "\n"); // Replace \\n -> \n. 124 } 125 return record; 126} 127 128// Get all moods with handles included. 129// 🗒️ There will be no `\n` filtering here, so it should happen on client rendering. 130async function allMoods(database, handles = null) { 131 const collection = database.db.collection("moods"); 132 const matchStage = { deleted: { $ne: true } }; 133 134 // Refactored to support comma-separated list of handles 135 if (handles) { 136 const handleList = handles 137 .split(",") 138 .map((h) => h.trim().replace(/^@/, "")) 139 .filter(Boolean); 140 141 matchStage["handleInfo.handle"] = { $in: handleList }; 142 } 143 144 const pipeline = [ 145 { 146 $lookup: { 147 from: "@handles", 148 localField: "user", 149 foreignField: "_id", 150 as: "handleInfo", 151 }, 152 }, 153 { $unwind: "$handleInfo" }, 154 { 155 $match: matchStage, 156 }, 157 { 158 $project: { 159 _id: 0, 160 mood: 1, 161 when: 1, 162 handle: { $concat: ["@", "$handleInfo.handle"] }, 163 atproto: 1, 164 }, 165 }, 166 { $sort: { when: -1 } }, 167 ]; 168 169 const records = await collection.aggregate(pipeline).toArray(); 170 return records; 171} 172 173/** 174 * Get a single mood by handle and ATProto rkey (for permalinks) 175 * @param {Object} database - MongoDB connection 176 * @param {string} handle - Handle like "@jeffrey" or "jeffrey" 177 * @param {string} rkey - ATProto record key 178 * @returns {Promise<Object|null>} Mood object or null 179 */ 180async function getMoodByRkey(database, handle, rkey) { 181 const collection = database.db.collection("moods"); 182 183 // Normalize handle (remove @ if present) 184 const normalizedHandle = handle.replace(/^@/, ""); 185 186 const pipeline = [ 187 { 188 $lookup: { 189 from: "@handles", 190 localField: "user", 191 foreignField: "_id", 192 as: "handleInfo", 193 }, 194 }, 195 { $unwind: "$handleInfo" }, 196 { 197 $match: { 198 "handleInfo.handle": normalizedHandle, 199 "atproto.rkey": rkey, 200 deleted: { $ne: true }, 201 }, 202 }, 203 { 204 $project: { 205 _id: 0, 206 mood: 1, 207 when: 1, 208 handle: { $concat: ["@", "$handleInfo.handle"] }, 209 atproto: 1, 210 bluesky: 1, 211 }, 212 }, 213 ]; 214 215 const records = await collection.aggregate(pipeline).toArray(); 216 return records[0] || null; 217} 218 219 220export { connect, ObjectId, moodFor, allMoods, getMoodByRkey }; 221 222// Demo code from MongoDB's connection page: (23.08.15.19.59) 223 224// Create a MongoClient with a MongoClientOptions object to set the Stable API version 225// const client = new MongoClient(uri, { 226// serverApi: { 227// version: ServerApiVersion.v1, 228// strict: true, 229// deprecationErrors: true, 230// } 231// }); 232 233// async function run() { 234// try { 235// // Connect the client to the server (optional starting in v4.7) 236// await client.connect(); 237// // Send a ping to confirm a successful connection 238// await client.db("admin").command({ ping: 1 }); 239// console.log("Pinged your deployment. You successfully connected to MongoDB!"); 240// } finally { 241// // Ensures that the client will close when you finish/error 242// await client.close(); 243// } 244// } 245// run().catch(console.dir); 246 247// 🧻🖌️ Paintings media migrations: 248 249import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3"; 250 251async function listAndSaveMedia(mediaType) { 252 if (!["painting", "piece"].includes(mediaType)) { 253 throw new Error( 254 'Invalid media type provided. Expected "painting" or "piece"', 255 ); 256 } 257 258 const s3User = new S3Client({ 259 endpoint: "https://" + process.env.USER_ENDPOINT, 260 region: "us-east-1", 261 credentials: { 262 accessKeyId: process.env.ART_KEY, 263 secretAccessKey: process.env.ART_SECRET, 264 }, 265 }); 266 267 const { db, disconnect } = await connect(); 268 const collection = db.collection(mediaType + "s"); // Adjust collection name based on media type 269 await collection.createIndex({ user: 1 }); 270 await collection.createIndex({ when: 1 }); 271 await collection.createIndex({ slug: 1 }); 272 await collection.createIndex({ slug: 1, user: 1 }, { unique: true }); 273 274 // Iterate through each user's top-level directory 275 const userDirectories = await listDirectories({ 276 s3: s3User, 277 bucket: process.env.USER_SPACE_NAME, 278 }); 279 280 for (const dir of userDirectories) { 281 // Check for `auth0` prefix and list its subdirectories 282 if (dir.startsWith("auth0")) { 283 console.log("Dir:", dir); 284 285 const subDirectories = await listDirectories( 286 { s3: s3User, bucket: process.env.USER_SPACE_NAME }, 287 dir, 288 ); 289 290 // Check if the media type subdirectory exists 291 if (subDirectories.includes(dir + mediaType + "/")) { 292 const files = await listFiles( 293 { s3: s3User, bucket: process.env.USER_SPACE_NAME }, 294 dir + mediaType + "/", 295 ); 296 297 for (const file of files) { 298 const extension = mediaType === "painting" ? ".png" : ".mjs"; 299 // TODO: Eventually add other media types... 300 301 if (file.endsWith(extension)) { 302 const slug = file.split("/").pop().replace(extension, ""); 303 const user = dir.split("/")[0]; 304 const existingRecord = await collection.findOne({ slug, user }); 305 if (!existingRecord) { 306 const record = { 307 slug, 308 user, 309 when: new Date(), 310 }; 311 await collection.insertOne(record); 312 console.log(`✅ Added ${mediaType} entry for:`, slug); 313 } else { 314 console.log(`⚠️ ${mediaType} already exists for:`, slug); 315 } 316 } 317 } 318 } 319 } 320 } 321 322 disconnect(); 323} 324 325// Helper function to list directories for a given S3 client 326async function listDirectories(client, prefix = "") { 327 const params = { Bucket: client.bucket, Delimiter: "/", Prefix: prefix }; 328 const response = await client.s3.send(new ListObjectsV2Command(params)); 329 return response.CommonPrefixes 330 ? response.CommonPrefixes.map((prefix) => prefix.Prefix) 331 : []; 332} 333 334// Helper function to list files for a given S3 client and prefix 335async function listFiles(client, prefix) { 336 const params = { Bucket: client.bucket, Prefix: prefix }; 337 const response = await client.s3.send(new ListObjectsV2Command(params)); 338 return response.Contents.map((file) => file.Key); 339} 340 341export { listAndSaveMedia };