
MongoDB to Convex: A Step-by-Step Data Migration Script

Ah, data migration. The unsung hero of the developer’s life. It’s like moving houses, but instead of furniture, you’re hauling bits and bytes from one database to another. And just like moving, it’s always more complicated than you think. In this article, I’ll walk you through how I wrote a script to migrate data from MongoDB to Convex (shoutout to convex.dev), and hopefully, you’ll learn something — or at least get a good laugh.
The Setup: MongoDB and Convex Sitting in a Tree First, let’s set the stage. I had a MongoDB database with over 30 tables and over 2M documents that relate to one another. But MongoDB, while great for many things, wasn’t cutting it for our new app’s real-time needs. Enter Convex, a backend-as-a-service that promised to make my life easier with its real-time capabilities and serverless architecture. The only problem? Getting all that data from MongoDB into Convex.
So, I rolled up my sleeves, brewed a pot of coffee, and got to work.
The Code: A Tale of Two Databases Here’s the script I wrote to migrate the data. It’s a bit of a beast, but I’ll break it down for you. (You can find the full code at the end of this article, but let’s walk through the highlights.)
Step 1: Connecting to MongoDB and Convex
First things first: I needed to connect to both databases. MongoDB was easy — I just used the MongoClient
from the mongodb
package. Convex, on the other hand, required a bit more setup, including an auth token.
1const mongoClient = new MongoClient(MONGODB_URI);
2const convexClient = new ConvexHttpClient(CONVEX_URL);
3
4await mongoClient.connect();
5convexClient.setAuth(authToken);
6
Pro tip: If your auth token is invalid, Convex will throw an error faster than you can say “unauthorized.” So, make sure you’ve got the right token.
Step 2: Filtering and Counting Documents Next, I needed to filter the team/organization/workspace object in MongoDB that I wanted to migrate. I created a filter only include documents from specific teams and to make sure that the object was not deleted.
1const filter = {
2 team: { $in: [
3 new ObjectId('628...'),
4 new ObjectId('628...')
5 ]},
6 isDeleted: { $ne: true }
7};
8
9const contactsCount = await contactsCollection.countDocuments(filter);
10const taskCount = await tasksCollection.countDocuments(filter);
11// ...and so on
12
I console logged This gave me a sense of how much data I was dealing with. Spoiler: It was a lot.
Step 3: The Migration Loop Now, the fun part: the migration loop. I processed the data in batches of 100 to avoid overwhelming the system (Convex only allows for a little over 16k). For each contact, I checked if it had already been merged (using a Set to track processed IDs) and skipped it if it had.
1for (let skip = skipCount; ; skip += batchSize) {
2 const batch = await contactsCollection.find(filter).skip(skip).limit(batchSize).toArray();
3 if (batch.length === 0) break;
4
5 for (const oldContact of batch) {
6 if (processedMongoIds.has(oldContact._id.toString())) {
7 console.log(`Contact already merged: ${oldContact.firstName} ${oldContact.lastName}`);
8 continue;
9 }
10 // ... process the contact ...
11 }
12}
13
This loop is the heart of the script. It’s where the magic (and the headaches) happen.
Step 4: Formatting Data for Convex I had changed my schema in Convex, so I had to reformat the MongoDB data to fit. This included mapping phone numbers, emails, and addresses into my new structure.
1const phoneNumbers = [];
2if (oldContact.phoneNumbers?.length > 0) {
3 phoneNumbers.push(...oldContact.phoneNumbers.map(p => ({
4 label: p.phoneLabel || "Other",
5 number: p.phone,
6 isBad: p.isBadNumber || false,
7 isPrimary: p.isPrimary || false
8 })));
9}
10
I also had to handle edge cases, like contacts with no first or last name. (Yes, those exist. No, I don’t know why.)
Step 5: Creating Contacts and Tasks in Convex Once the data was formatted, I used Convex mutations to create new records, tasks, tags, activities… and so on.
1const result = await convexClient.mutation('contacts:create', newContact);
2
Step 6: Error Handling and Retries Of course, nothing ever goes perfectly. I added error handling and a retry mechanism to deal with hiccups like network issues or cursor timeouts. Of course, nothing ever goes perfectly. I added error handling and a retry mechanism to deal with hiccups like network issues or cursor timeouts
1async function retryOperation(operation, maxRetries = 3, delay = 1000) {
2 for (let attempt = 1; attempt <= maxRetries; attempt++) {
3 try {
4 return await operation();
5 } catch (error) {
6 if (attempt === maxRetries) throw error;
7 console.log(`Attempt ${attempt} failed, retrying in ${delay}ms...`);
8 await new Promise(resolve => setTimeout(resolve, delay));
9 delay *= 2; // Exponential backoff
10 }
11 }
12}
13
14
The Aftermath: Lessons Learned After hours of debugging, coffee, and the occasional existential crisis, the migration was complete. Here’s what I learned:
- Batch processing is your friend. It keeps things manageable and prevents your script from crashing under its own weight.
- Error handling is non-negotiable. Things will go wrong, so plan for it.
- Log everything. When something breaks (and it will), you’ll want to know where and why.
- Convex is awesome. Once the data was in, Convex made real-time updates a breeze.
The Code Here’s the full script for your reading pleasure. Feel free to adapt it for your own migrations — just don’t forget the coffee.
1import { MongoClient, ObjectId } from 'mongodb';
2import { ConvexHttpClient } from 'convex/browser';
3
4const MONGODB_URI = 'YOUR MONGO URI';
5const CONVEX_URL = 'CONVEX CLOUD URL';
6const createdContacts = new Map();
7async function migrateProperties() {
8 const mongoClient = new MongoClient(MONGODB_URI);
9 const convexClient = new ConvexHttpClient(CONVEX_URL);
10 try {
11 console.log("Connecting to MongoDB...");
12 await mongoClient.connect();
13 const db = mongoClient.db('propbear');
14 console.log("Connected to MongoDB.");
15 console.log("Authenticating with Convex...");
16
17 const authToken = "YOUR AUTH TOKEN";
18 convexClient.setAuth(authToken);
19 const user = await convexClient.query('users:viewer');
20 if (!user) {
21 throw new Error("Authentication failed");
22 }
23 console.log("Authenticated with Convex.");
24 const propertiesCollection = db.collection('propertydetails');
25 const contactsCollection = db.collection('contactdetails');
26 const tasksCollection = db.collection('tasks');
27 const linkedPropertyDetailsCollection = db.collection('linkedpropertydetails');
28 const filter = {
29 team: { $in: [
30 new ObjectId('628...'),
31 new ObjectId('627...')
32 ]}
33 };
34 const propertyCount = await propertiesCollection.countDocuments(filter);
35 const contactCount = await contactsCollection.countDocuments(filter);
36 const taskCount = await tasksCollection.countDocuments(filter);
37 const linkedPropertyDetailsCount = await linkedPropertyDetailsCollection.countDocuments(filter);
38
39 console.log("Mongo Properties:", propertyCount);
40 console.log("Mongo Contacts:", contactCount);
41 console.log("Mongo Tasks:", taskCount);
42 console.log("Mongo Linked Property Details:", linkedPropertyDetailsCount);
43 console.log("Starting migration...");
44 let migratedCount = 0;
45 let errorCount = 0;
46 let skippedCount = 0;
47 const batchSize = 100;
48 let lastId = null;
49 const skipCount = 6800;
50 const skipCursor = propertiesCollection.find(filter)
51 .sort({ _id: 1 })
52 .skip(skipCount)
53 .limit(1);
54
55 const lastSkippedDoc = await skipCursor.next();
56 if (lastSkippedDoc) {
57 lastId = lastSkippedDoc._id;
58 }
59 while (true) {
60 const query = lastId ? { ...filter, _id: { $gt: lastId } } : filter;
61 const cursor = propertiesCollection.find(query).sort({ _id: 1 }).limit(batchSize);
62 let batch = await retryOperation(async () => await cursor.toArray());
63 if (batch.length === 0) {
64 console.log("No more properties to process. Migration completed.");
65 break;
66 }
67 let allSkipped = true;
68 for (const oldProperty of batch) {
69 try {
70 async function recordExistsByMongoId(client, table, mongoId) {
71 try {
72 const result = await client.query(`${table}:getByMongoId`, { mongoId });
73 return result ? result._id : null;
74 } catch (error) {
75 console.error(`Error checking if ${table} record exists:`, error);
76 return null;
77 }
78 }
79 try {
80 const existingPropertyId = await recordExistsByMongoId(convexClient, 'properties', oldProperty._id.toString());
81
82 if (existingPropertyId) {
83 console.log(`Property already exists: ${oldProperty.name}`, existingPropertyId);
84 skippedCount++;
85 continue;
86 }
87 allSkipped = false;
88 let contactId = null;
89 if (oldProperty.owner) {
90 const oldContact = await contactsCollection.findOne({ _id: oldProperty.owner });
91 if (oldContact) {
92 contactId = await recordExistsByMongoId(convexClient, 'contacts', oldContact._id.toString());
93 if (!contactId) {
94 const newContact = {
95 mongoId: oldContact._id.toString(),
96 recordType: "contacts",
97 firstName: oldContact.firstName || "",
98 lastName: oldContact.lastName || "",
99 fullName: `${oldContact.firstName || ""} ${oldContact.lastName || ""}`.trim(),
100 orgId: mapOrgId(oldContact.team?.toString()) || "nd70p3ngxkh8n7chaddb81tjpx7166mr",
101 phone: (() => {
102 let phoneNumbers = [];
103 if (oldContact.phoneNumbers && oldContact.phoneNumbers.length > 0) {
104 phoneNumbers = oldContact.phoneNumbers.map(p => ({
105 label: p.phoneLabel || "Other",
106 number: p.phone,
107 isPrimary: p.isPrimary || false,
108 isBad: oldContact.phoneDetails?.isBadNumber || false
109 }));
110 }
111 if (oldContact.phone && !phoneNumbers.some(p => p.number === oldContact.phone)) {
112 phoneNumbers.push({
113 label: oldContact.phoneDetails?.phoneLabel || "Other",
114 number: oldContact.phone,
115 isPrimary: oldContact.phoneDetails?.isPrimary || phoneNumbers.length === 0,
116 isBad: oldContact.phoneDetails?.isBadNumber || false
117 });
118 }
119 return phoneNumbers.length > 0 ? phoneNumbers : undefined;
120 })(),
121 email: (() => {
122 let emails = [];
123 if (oldContact.emails && oldContact.emails.length > 0) {
124 emails = oldContact.emails.map(e => ({
125 label: e.emailLabel || "Other",
126 address: e.email,
127 isPrimary: e.isPrimary || false,
128 isBad: oldContact.emailDetails?.isBadEmail || false
129 }));
130 }
131 if (oldContact.email && !emails.some(e => e.address === oldContact.email)) {
132 emails.push({
133 label: oldContact.emailDetails?.emailLabel || "Other",
134 address: oldContact.email,
135 isPrimary: oldContact.emailDetails?.isPrimary || emails.length === 0,
136 isBad: oldContact.emailDetails?.isBadEmail || false
137 });
138 }
139 return emails.length > 0 ? emails : [{ label: "Other", address: "", isPrimary: true, isBad: false }];
140 })(),
141 address: oldContact.address ? [{
142 label: "Home",
143 street: oldContact.address,
144 city: oldContact.city,
145 state: oldContact.state,
146 zip: oldContact.zipCode ? parseFloat(oldContact.zipCode) : undefined,
147 }] : undefined,
148 summary: oldContact.stickyNote,
149 };
150 Object.keys(newContact).forEach(key => newContact[key] === undefined && delete newContact[key]);
151 try {
152 let contactId;
153 if (createdContacts.has(oldContact._id.toString())) {
154 contactId = createdContacts.get(oldContact._id.toString());
155 await convexClient.mutation('contacts:updateContact', {
156 id: contactId,
157 });
158 } else {
159 contactId = await convexClient.mutation('contacts:createContactWithoutEnrichment', newContact);
160 createdContacts.set(oldContact._id.toString(), contactId);
161 }
162 if (oldContact.tags && oldContact.tags.length > 0) {
163 for (const tag of oldContact.tags) {
164 if (tag !== "Import") {
165 try {
166 const tagId = await createOrGetTag(
167 convexClient,
168 tag.name || tag,
169 "contacts",
170 contactId,
171 newContact.orgId,
172 await mapUserId(oldContact.createdBy?.toString()),
173 tag._id ? tag._id.toString() : undefined
174 );
175 if (tagId) {
176 console.log(`Created or got tag: ${tag.name || tag}`, tagId);
177 } else {
178 console.log(`Failed to create or get tag: ${tag.name || tag}`);
179 }
180 } catch (error) {
181 console.error(`Error processing tag ${tag.name || tag} for contact:`, error);
182 }
183 }
184 }
185 }
186 console.log(`Created contact: ${newContact.firstName} ${newContact.lastName}`, contactId);
187 // Create tasks for the contact
188 if (oldContact.tasks && oldContact.tasks.length > 0) {
189 for (const taskId of oldContact.tasks) {
190 const oldTask = await tasksCollection.findOne({ _id: taskId });
191 if (oldTask) {
192 const existingTaskId = await recordExistsByMongoId(convexClient, 'tasks', oldTask._id.toString());
193 if (!existingTaskId) {
194 const newTask = {
195 mongoId: oldTask._id.toString(),
196 orgId: newContact.orgId,
197 title: oldTask.task,
198 description: oldTask.description,
199 dueDate: oldTask.endDate ? new Date(oldTask.endDate).toISOString() : undefined,
200 linkedRecord: contactId,
201 assignedTo: await mapUserId(oldTask.createdForUserId?.toString()),
202 priority: mapPriority(oldTask.taskPriority),
203 column: mapStatus(oldTask.status),
204 };
205 Object.keys(newTask).forEach(key => newTask[key] === undefined && delete newTask[key]);
206 try {
207 const taskResult = await convexClient.mutation('tasks:createTask', newTask);
208 console.log(`Created contact task: ${newTask.title}`, taskResult);
209 } catch (error) {
210 console.error(`Error creating contact task ${newTask.title}:`, error);
211 }
212 } else {
213 console.log(`Task already exists: ${oldTask.task}`, existingTaskId);
214 }
215 }
216 }
217 }
218 // Create related contacts
219 if (oldContact.relatedContacts && oldContact.relatedContacts.length > 0) {
220 for (const relatedContact of oldContact.relatedContacts) {
221 const newRelatedContact = {
222 label: "Related Contact",
223 firstName: relatedContact.name.split(' ')[0] || "",
224 lastName: relatedContact.name.split(' ').slice(1).join(' ') || "",
225 email: [{
226 label: "Other",
227 address: relatedContact.email,
228 isBad: false,
229 isPrimary: true
230 }],
231 phone: [{
232 label: "Other",
233 number: relatedContact.phone,
234 isBad: false,
235 isPrimary: true
236 }],
237 orgId: newContact.orgId,
238 recordId: contactId
239 };
240 try {
241 const relatedContactResult = await convexClient.mutation('relatedContacts:createRelatedContact', newRelatedContact);
242 console.log(`Created related contact: ${newRelatedContact.firstName} ${newRelatedContact.lastName}`, relatedContactResult);
243 } catch (error) {
244 console.error(`Error creating related contact ${newRelatedContact.firstName} ${newRelatedContact.lastName}:`, error);
245 }
246 }
247 }
248 } catch (error) {
249 console.error(`Error creating contact ${newContact.firstName} ${newContact.lastName}:`, error);
250 }
251 } else {
252 console.log(`Contact already exists: ${oldContact.firstName} ${oldContact.lastName}`, contactId);
253 }
254 }
255 }
256 // Create the property
257 const newProperty = {
258 mongoId: oldProperty._id.toString(),
259 name: oldProperty.name,
260 recordType: "properties",
261 image: oldProperty.propertyImages && oldProperty.propertyImages.length > 0 ? oldProperty.propertyImages[0] : undefined,
262 orgId: mapOrgId(oldProperty.team?.toString()) || "nd70p3ngxkh8n7chaddb81tjpx7166mr",
263 propertyType: oldProperty.type || undefined,
264 address: {
265 street: oldProperty.address ? toProperCase(oldProperty.address) : toProperCase(oldProperty.name),
266 city: oldProperty.city ? toProperCase(oldProperty.city) : undefined,
267 state: oldProperty.state ? oldProperty.state.toUpperCase() : undefined,
268 zip: oldProperty.zipCode ? parseFloat(oldProperty.zipCode) : undefined,
269 },
270 location: oldProperty.coordinates && oldProperty.coordinates.lng && oldProperty.coordinates.lat ? {
271 type: "Point",
272 coordinates: [
273 parseFloat(oldProperty.coordinates.lng),
274 parseFloat(oldProperty.coordinates.lat)
275 ],
276 } : { type: "Point", coordinates: [0, 0] }, // Default coordinates if not available
277 isDeleted: false,
278 tags: oldProperty.tags ? oldProperty.tags.map(tag => tag._id?.toString()) : [],
279 yearBuilt: oldProperty.propertyData?.yearBuilt ? parseFloat(oldProperty.propertyData.yearBuilt) : undefined,
280 squareFootage: oldProperty.propertyData?.acres ? oldProperty.propertyData.acres * 43560 : undefined,
281 units: oldProperty.units ? parseFloat(oldProperty.units) : undefined,
282 price: oldProperty.propertyData?.forsale?.price ? parseFloat(oldProperty.propertyData.forsale.price) : undefined,
283 parcelNumber: oldProperty.parcelNo || oldProperty.propertyData?.parcelNo || undefined,
284 saleDate: oldProperty.lastSold ? new Date(parseFloat(oldProperty.lastSold)).toISOString() : undefined,
285 salePrice: oldProperty.propertyData?.soldPrice ? parseFloat(oldProperty.propertyData.soldPrice) : undefined,
286 landValue: oldProperty.landValue ? parseFloat(oldProperty.landValue) : undefined,
287 buildingValue: oldProperty.propertyData?.bldgValue ? parseFloat(oldProperty.propertyData.bldgValue) : undefined,
288 status: oldProperty.status,
289 primaryUse: oldProperty.propertyData?.occupancy || undefined,
290 construction: oldProperty.propertyData?.construction || undefined,
291 lotSize: oldProperty.propertyData?.acres ? parseFloat(oldProperty.propertyData.acres) : undefined,
292 zoning: oldProperty.propertyData?.zoning || undefined,
293 meterType: oldProperty.propertyData?.meterType || undefined,
294 class: oldProperty.propertyData?.class || undefined,
295 structures: oldProperty.propertyData?.structures ? parseInt(oldProperty.propertyData.structures) : undefined,
296 parking: oldProperty.propertyData?.parking || undefined,
297 };
298 // Remove createdBy field as it's not allowed
299 delete newProperty.createdBy;
300 Object.keys(newProperty).forEach(key => newProperty[key] === undefined && delete newProperty[key]);
301 // Ensure location is always present
302 if (!newProperty.location || isNaN(newProperty.location.coordinates[0]) || isNaN(newProperty.location.coordinates[1])) {
303 newProperty.location = { type: "Point", coordinates: [0, 0] };
304 }
305 try {
306 const result = await convexClient.mutation('properties:createPropertyWithoutEnrichment', newProperty);
307 console.log(`Created property: ${newProperty.name}`, result);
308 // Process tags for the property
309 if (oldProperty.tags && oldProperty.tags.length > 0) {
310 for (const tag of oldProperty.tags) {
311 if (tag !== "Import") {
312 try {
313 const tagId = await createOrGetTag(
314 convexClient,
315 tag.name || tag,
316 "properties",
317 result,
318 newProperty.orgId,
319 await mapUserId(oldProperty.creator?.toString()),
320 tag._id ? tag._id.toString() : undefined
321 );
322 if (tagId) {
323 console.log(`Created or got tag: ${tag.name || tag}`, tagId);
324 } else {
325 console.log(`Failed to create or get tag: ${tag.name || tag}`);
326 }
327 } catch (error) {
328 console.error(`Error processing tag ${tag.name || tag} for property:`, error);
329 }
330 }
331 }
332 }
333 // Create tasks for the property
334 if (oldProperty.tasks && oldProperty.tasks.length > 0) {
335 for (const taskId of oldProperty.tasks) {
336 const oldTask = await tasksCollection.findOne({ _id: taskId });
337 if (oldTask) {
338 const existingTaskId = await recordExistsByMongoId(convexClient, 'tasks', oldTask._id.toString());
339 if (!existingTaskId) {
340 const newTask = {
341 mongoId: oldTask._id.toString(),
342 orgId: newProperty.orgId,
343 title: oldTask.task,
344 description: oldTask.description,
345 dueDate: oldTask.endDate ? new Date(oldTask.endDate).toISOString() : undefined,
346 linkedRecord: result,
347 assignedTo: await mapUserId(oldTask.createdForUserId?.toString()),
348 priority: mapPriority(oldTask.taskPriority),
349 column: mapStatus(oldTask.status),
350 };
351 Object.keys(newTask).forEach(key => newTask[key] === undefined && delete newTask[key]);
352 try {
353 const taskResult = await convexClient.mutation('tasks:createTask', newTask);
354 console.log(`Created property task: ${newTask.title}`, taskResult);
355 } catch (error) {
356 console.error(`Error creating property task ${newTask.title}:`, error);
357 }
358 } else {
359 console.log(`Task already exists: ${oldTask.task}`, existingTaskId);
360 }
361 }
362 }
363 }
364 if (contactId) {
365 await convexClient.mutation('contactLinkedProperties:linkPropertyFromMongo', {
366 contactId: contactId,
367 propertyId: result,
368 relation: 'Owner',
369 orgId: newProperty.orgId,
370 });
371 console.log(`Linked contact ${contactId} as Owner to property ${result}`);
372 }
373 // After creating the main property and owner contact
374 await processLinkedProperties(convexClient, oldProperty, result, newProperty.orgId, {
375 linkedPropertyDetailsCollection,
376 contactsCollection
377 }, createdContacts);
378 migratedCount++;
379 } catch (error) {
380 console.error(`Error migrating property ${newProperty.name}:`, error);
381 errorCount++;
382 }
383 } catch (error) {
384 console.error(`Error migrating property ${oldProperty.name}:`, error);
385 errorCount++;
386 }
387 } catch (error) {
388 if (error.code === 43 && error.codeName === 'CursorNotFound') {
389 console.error('Cursor not found, restarting migration from the last successful point');
390 // You might want to implement a way to track the last successfully migrated property
391 // and restart from there instead of from the beginning
392 break;
393 }
394 throw error; // Rethrow other errors
395 }
396 // Update lastId after processing each property
397 lastId = oldProperty._id;
398 }
399 if (allSkipped) {
400 console.log(`All ${batchSize} properties in this batch were already migrated. Moving to next batch.`);
401 }
402 console.log(`Processed batch. Total migrated: ${migratedCount}, Skipped: ${skippedCount}, Errors: ${errorCount}`);
403 }
404 console.log(`Migration completed. Total migrated: ${migratedCount}, Skipped: ${skippedCount}, Errors: ${errorCount}`);
405 } catch (error) {
406 console.error('Error during migration:', error);
407 } finally {
408 await mongoClient.close();
409 }
410}
411async function processLinkedProperties(convexClient, oldProperty, propertyId, orgId, collections, createdContacts) {
412 const { linkedPropertyDetailsCollection, contactsCollection } = collections;
413 const batchSize = 10; // Reduced batch size
414 let skip = 0;
415 while (true) {
416 const linkedPropertyDetails = await linkedPropertyDetailsCollection.find({ property: oldProperty._id })
417 .skip(skip)
418 .limit(batchSize)
419 .toArray();
420 if (linkedPropertyDetails.length === 0) break;
421 for (const link of linkedPropertyDetails) {
422 await processLinkedProperty(convexClient, link, propertyId, orgId, contactsCollection, createdContacts);
423 await new Promise(resolve => setTimeout(resolve, 100)); // 100ms delay between each link
424 }
425 skip += batchSize;
426 await new Promise(resolve => setTimeout(resolve, 1000)); // 1 second delay between batches
427 }
428}
429async function processLinkedProperty(convexClient, link, propertyId, orgId, contactsCollection, createdContacts) {
430 try {
431 const oldContact = await contactsCollection.findOne({ _id: link.contact });
432 if (oldContact && oldContact._id) {
433 let linkedContactId;
434 if (createdContacts.has(oldContact._id.toString())) {
435 linkedContactId = createdContacts.get(oldContact._id.toString());
436 } else {
437 // Create the linked contact
438 const newLinkedContact = {
439 mongoId: oldContact._id.toString(),
440 firstName: oldContact.firstName || "",
441 lastName: oldContact.lastName || "",
442 fullName: `${oldContact.firstName || ""} ${oldContact.lastName || ""}`.trim(),
443 email: oldContact.email ? [{
444 label: "Other",
445 address: oldContact.email,
446 isBad: false,
447 isPrimary: true
448 }] : [{ label: "Other", address: "", isPrimary: true, isBad: false }],
449 orgId: orgId,
450 recordType: "contacts",
451 };
452 // Add company name if it exists
453 if (oldContact.company) {
454 delete oldContact.company;
455 }
456 // Use company name as fullName if no individual name is provided
457 if (!newLinkedContact.fullName && newLinkedContact.company) {
458 newLinkedContact.fullName = newLinkedContact.company;
459 }
460 // Skip truly empty contacts
461 if (!newLinkedContact.fullName.trim() && !newLinkedContact.email[0].address.trim() && !newLinkedContact.company) {
462 console.log(`Skipping empty linked contact for property ${propertyId}`);
463 return;
464 }
465 try {
466 linkedContactId = await convexClient.mutation('contacts:createContactWithoutEnrichment', newLinkedContact);
467 console.log(`Created linked contact: ${newLinkedContact.fullName || newLinkedContact.company}`, linkedContactId);
468 await convexClient.mutation('contactLinkedProperties:linkPropertyFromMongo', {
469 contactId: linkedContactId,
470 propertyId: propertyId,
471 relation: link.relation || "Other",
472 orgId: orgId,
473 });
474 console.log(`Linked contact ${linkedContactId} as ${link.relation || "Other"} to property ${propertyId}`);
475 createdContacts.set(oldContact._id.toString(), linkedContactId);
476 } catch (error) {
477 console.error(`Error creating linked contact ${newLinkedContact.fullName || newLinkedContact.company}:`, error);
478 return;
479 }
480 }
481 } else {
482 console.error(`Invalid linked contact found for property ${propertyId}:`, link);
483 }
484 } catch (error) {
485 console.error(`Error processing linked property for property ${propertyId}:`, error);
486 }
487}
488/**
489 * @param {string | undefined} oldId
490 * @returns {string | null}
491 */
492function mapOrgId(oldId) {
493 if (!oldId) return "nd70p3ngxkh8n7chaddb81tjpx7166mr";
494 const orgIdMap = {
495 '628..': 'nd7b...', // first is mongoId second is convex _id
496 '627...': 'nd7d...'
497 };
498 return orgIdMap[oldId] || null;
499}
500function mapUserId(oldId) {
501 if (!oldId) return null;
502
503 const userIdMap = {
504 '628c...': 'jx7...', // first is mongoId second is convex _id
505 ...
506 };
507 return userIdMap[oldId] || null;
508}
509function mapPriority(oldPriority) {
510 const priorityMap = {
511 'Low': 'low',
512 'Medium': 'medium',
513 'High': 'high'
514 };
515 return priorityMap[oldPriority] || 'no-priority';
516}
517function mapStatus(oldStatus) {
518 const statusMap = {
519 'In Progress': 'in-progress',
520 'Completed': 'done',
521 'Not Started': 'todo'
522 };
523 return statusMap[oldStatus] || 'todo';
524}
525function toProperCase(str) {
526 return str.replace(/\w\S*/g, function(txt) {
527 if (isNaN(txt)) {
528 return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();
529 } else {
530 return txt;
531 }
532 });
533}
534async function retryOperation(operation, maxRetries = 3, delay = 1000) {
535 for (let attempt = 1; attempt <= maxRetries; attempt++) {
536 try {
537 return await operation();
538 } catch (error) {
539 if (attempt === maxRetries) throw error;
540 console.log(`Attempt ${attempt} failed, retrying in ${delay}ms...`);
541 await new Promise(resolve => setTimeout(resolve, delay));
542 delay *= 2; // Exponential backoff
543 }
544 }
545}
546async function createOrGetTag(convexClient, tagName, recordType, recordId, orgId, createdBy, mongoId = undefined) {
547 try {
548 let existingTag;
549
550 // Try to get the tag by mongoId if it exists
551 if (mongoId) {
552 existingTag = await convexClient.query('tags:getTagByMongoId', { mongoId });
553 }
554 if (existingTag) {
555 // If the tag exists, return its ID
556 return existingTag._id;
557 } else {
558 // If the tag doesn't exist, create a new one
559 const newTag = {
560 name: tagName,
561 recordType,
562 orgId,
563 createdBy,
564 mongoId
565 };
566 const tagId = await convexClient.mutation('tags:createTag', newTag);
567 // Link the tag to the record
568 await convexClient.mutation('tags:linkTagToRecord', {
569 tagId,
570 recordId
571 });
572 return tagId;
573 }
574 } catch (error) {
575 console.error(`Error creating or getting tag ${tagName}:`, error);
576 return null;
577 }
578}
579
580migrateProperties().catch(console.error);
581
582
Final Thoughts Data migration isn’t glamorous, but it’s a necessary evil. With the right tools and a bit of patience, you can move mountains of data without losing your sanity. And if all else fails, remember: there’s always more coffee.
More Examples: https://github.com/robertalv/merge-mongodb-convex
Happy coding! 🚀
Convex is the sync platform with everything you need to build your full-stack project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.