Monorepo for Aesthetic.Computer
aesthetic.computer
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 };