Skip to Content
MigrationMigration: MongoDB to PostgreSQL

Migration: MongoDB to PostgreSQL

This package migrates the Leadmetrics production data from Azure Cosmos DB for MongoDB API into the v3 PostgreSQL database managed by Prisma. The migrator is a TypeScript CLI (@leadmetrics/migration) that reads from MongoDB via a cursor-based batch loop, maps each document to a Prisma-compatible row through a typed CollectionMap file, and writes to PostgreSQL using createMany with skipDuplicates: true so that every step is safe to re-run. All writes are skipped in dry-run mode, which is useful for inspecting field-mapping logic without touching the target database.

Quick start

# 1. Copy and fill in the two required variables cp migration/.env.example migration/.env # 2. Dry-run — validates mapping logic, no DB writes pnpm --filter @leadmetrics/migration migrate:dry # 3. Live run — writes to PostgreSQL pnpm --filter @leadmetrics/migration migrate

Table of contents

DocumentContents
architecture.mdFolder structure, CollectionMap pattern, RelatedWriter, runner flow, dynamic imports
usage.mdEnvironment setup, all run commands, flags, re-run safety, adding steps
map-files.mdAnnotated map file template, relatedWriter and setup() examples, authoring tips
known-issues.mdSkip breakdowns, legacy plans, auto-generated invoice numbers, passwords

Phase 1 results

These are the final row counts after a full live run against the production MongoDB instance.

StepKeyMigratedSkippedTarget tables
1offering1 offering, 5 regions, 10 plans0offering, region, plan
2tenant280 tenants, competitors expanded0tenant, competitor
3user359 users, 359 accounts0user, account
4member0 (main), 1,407 (relatedWriter)359 (by design)tenant_member
5subscription22066subscription
6invoice28615invoice

The 359 “skipped” rows in the member step are not data loss — mapDoc intentionally returns null for all documents; all 1,407 tenant_member rows are written by the relatedWriter. See known-issues.md for the breakdown of the 66 subscription skips and 15 invoice skips.

Phase 2 results

StepKeyMigratedSkippedTarget tables
7strategy8 strategies, 189 logs0strategy, strategy_log
8strategy-history310strategy_version
9deliverable-plan220deliverable_plan
10goal351goal
11client-context290client_context
12report510report

The 1 skipped goal belongs to a tenant (6936d131994cdc348bf14c9e) that has no GoalsGroup in MongoDB. See known-issues.md for Phase 2 skip details.

Phase 3 results

StepKeyMigratedSkippedTarget tables
13keyword205,72121keyword
14keyword-group49 groups, 910 items0keyword_group, keyword_group_item
15channel2285connected_channel

The 5 channel skips: 2 docs with null tenantId, 2 “Bing WebMaster Tools” and 1 “ScholarCRM” with no matching row in channel_master. OAuth tokens (tokenInfo) are not migrated — tenants must reconnect channels after migration.

The keyword step processes 205K+ docs in ~84 seconds at batch size 1,000. Run it as a standalone step (--only keyword) to avoid shell timeouts.

Phase 4 results

StepKeyMigratedSkippedTarget tables
16lead20,137 leads, 44,128 contacts, 13,790 activity rows89lead, lead_contact, lead_activity

The 89 skipped leads have a tenantId not present in the migrated tenant table (orphaned). The contact collection (4 records, no tenantId field) and media_asset (Azure Blob Storage URLs; v3 source enum is pixabay\|unsplash only) were not migrated — see known-issues.md.

Phase 5 results

StepKeyMigratedSkippedTarget tables
17blog-post1,837 posts, 13,729 FAQs247blog_post, blog_post_faq
18social-post7170social_post

The 247 skipped blog posts have a tenantId not present in the migrated tenant table (orphaned). The v2 Activity collection (37,209 task-tracker items) was intentionally not migrated — see known-issues.md.

BlogPost.activityId and SocialPost.activityId were changed from String @unique to String? @unique (with onDelete: SetNull) before this phase ran. Migrated posts have activityId: null.

© 2026 Leadmetrics — Internal use only