Known Issues
Phase 1 skip summary
| Step | Key | Migrated | Skipped | Reason for skips |
|---|---|---|---|---|
| 1 | offering | 1 offering, 5 regions, 10 plans | 0 | — |
| 2 | tenant | 280 | 0 | — |
| 3 | user | 359 users + 359 accounts | 0 | — |
| 4 | member | 0 (main), 1,407 (relatedWriter) | 359 | By design — see below |
| 5 | subscription | 220 | 66 | Orphaned tenants and broken planInfo |
| 6 | invoice | 286 | 15 | Parent subscription was itself skipped |
Subscription skips (66 out of 286)
Orphaned tenants (~23 skips)
A subscription document references a tenantId that does not exist in the Tenant MongoDB collection. This happens when a tenant was hard-deleted from MongoDB after the subscription was created. The subscription setup() loads all valid tenant IDs from PostgreSQL (populated by the tenant step); any subscription whose tenantId is not in that set is skipped by mapDoc.
These records are unrecoverable without restoring the original tenant data in MongoDB. They represent genuinely deleted customers whose subscriptions were not cleaned up.
Broken planInfo (~43 skips)
A subscription document has a null or incomplete planInfo field, so the lookup key resolves to a string such as in:undefined or dubai:null rather than a valid regionCode:planSlug pair. The plan lookup returns undefined for these keys and mapDoc returns null.
These records are unrecoverable without correcting the source documents in MongoDB. They appear to be data entry errors or import artifacts from an older system version.
Invoice skips (15 out of 301)
All 15 skipped invoices reference a subscriptionId that was itself skipped in the subscription step (orphaned-tenant case). Since the parent subscription row does not exist in PostgreSQL, inserting the invoice would violate the foreign key constraint. The invoice setup() loads all valid subscription IDs from PostgreSQL; any invoice whose subscriptionId is not in that set is skipped.
These 15 invoices correspond exactly to the ~23 orphaned-tenant subscriptions. The slight mismatch in count (23 orphaned subscriptions, 15 orphaned invoices) means some of those deleted tenants had no invoices at all.
Member “skips” (359 out of 359)
The member step (04-member.map.ts) reads from the User collection — the same source as the user step — but its mapDoc function intentionally returns null for every document. All 1,407 tenant_member rows are written by the relatedWriter, which iterates the tenants[] array embedded in each user document and inserts one row per membership.
The 359 “skipped” entries in the summary reflect the design of the main pipeline (mapDoc returned null for each of the 359 user documents), not actual data loss. The relatedWriter count of 1,407 is the meaningful output for this step.
Before inserting each tenant_member row, the relatedWriter validates that both the userId and the tenantId exist in their respective PostgreSQL tables. Any membership referencing a user or tenant that was not migrated is silently dropped.
Legacy plans
Some subscriptions reference plan slugs that no longer appear in the current MongoDB Offering document — the plans were removed or renamed at some point after those subscriptions were created.
The LEGACY_PLANS array in 01-offering.map.ts declares these plans so they are created in PostgreSQL before the subscription step runs:
const LEGACY_PLANS: { regionSlug: string; planSlug: string }[] = [
{ regionSlug: "all", planSlug: "trial" },
];Legacy plans are created with priceAmount: 0, isActive: false, and billingCycle: "monthly". The upsert uses update: {} so that re-running the offering step never overwrites an existing legacy plan record.
To add a new legacy plan: append an entry to the LEGACY_PLANS array with the regionSlug (must match a region created by the offering step) and the planSlug as it appears in the MongoDB subscription documents.
Auto-generated invoice numbers
Some MongoDB invoice documents have no invoice number (the field is null or missing). The invoice map generates a synthetic number for these cases using the pattern AUTO-{mongoId}, where {mongoId} is the MongoDB _id string of the invoice document. This ensures all rows have a non-null invoiceNumber and makes it straightforward to identify auto-generated numbers if they need to be corrected later.
Passwords
MongoDB stores user passwords with a custom AES encryption scheme that is not reversible from the migration side. No original passwords are migrated. All users are assigned the temporary password Welcome@123 (bcrypt, cost factor 12). The bcrypt hash is computed once in the user step’s setup() function and reused for all 359 user rows.
Users should be prompted to change their password on first login after migration. The credential account rows created by the user relatedWriter use this hashed temporary password as the stored credential.
Phase 2 skip summary
| Step | Key | Migrated | Skipped | Reason for skips |
|---|---|---|---|---|
| 7 | strategy | 8 | 0 | — |
| 8 | strategy-history | 31 | 0 | — |
| 9 | deliverable-plan | 22 | 0 | — |
| 10 | goal | 35 | 1 | Orphaned goal (no GoalsGroup for tenant) |
| 11 | client-context | 29 | 0 | — |
| 12 | report | 51 | 0 | — |
Goal skip (1 out of 36)
One goal document references a tenantId (6936d131994cdc348bf14c9e) that has no corresponding GoalsGroup in MongoDB. The goal step’s setup() builds a tenantId → deliverablePlanId lookup from the deliverable_plan table; any goal whose tenant is absent from that lookup is skipped.
This is unrecoverable without knowing which GoalsGroup the goal was intended to belong to. It likely represents a data entry or cleanup inconsistency in the source system.
Null bytes in report content (fixed)
Several MongoDB Report documents contain null bytes (0x00) in their HTML content, which PostgreSQL rejects for UTF-8 text fields. The report map strips null bytes via a sanitize() helper before inserting.
ContentContext deduplication
MongoDB allows multiple ContentContext documents per tenant (one per contextType). The Prisma client_context table enforces tenantId @unique. The setup() function in 11-client-context.map.ts queries MongoDB directly, sorts by updatedOn descending in memory (Cosmos DB lacks a sort index on that field), and selects the single latest active document per tenant. All other docs are silently dropped.
Phase 3 skip summary
| Step | Key | Migrated | Skipped | Reason for skips |
|---|---|---|---|---|
| 13 | keyword | 205,721 | 21 | Orphaned tenants |
| 14 | keyword-group | 49 | 0 | — |
| 15 | channel | 228 | 5 | null tenantId (2); no channel_master row (3) |
Channel skips (5 out of 233)
Two docs have tenantId: null — corrupt source records. Three docs reference channel types (“Bing WebMaster Tools”, “ScholarCRM”) that have no row in the channel_master seeded catalog. The channel_master table is static/seeded; adding these types would require seeding new rows before re-running the channel step.
Channel token migration
tokenInfo, basicInfo, and apiKeyInfo on ConnectedChannel are intentionally left null. The v3 system stores these fields as encrypted JSON via @leadmetrics/crypto. OAuth access tokens also expire within hours. Tenants must reconnect their channels after migration.
Keyword step timeout
With 205K+ docs, the keyword step takes ~84 seconds. Run it as a standalone command to avoid shell-level process killing from shorter timeouts:
pnpm --filter @leadmetrics/migration migrate -- --only keywordThe step is fully resumable: createMany({ skipDuplicates: true }) silently skips already-written rows.
Phase 4 skip summary
| Step | Key | Migrated | Skipped | Reason |
|---|---|---|---|---|
| 16 | lead | 20,137 | 89 | Orphaned tenants |
Lead skips (89 out of 20,226)
Leads whose tenantId does not exist in the migrated tenant table. Same cause as subscription skips in Phase 1 — the parent tenant was deleted from MongoDB after the lead was created.
Contact collection not migrated
The MongoDB Contact collection contains only 4 documents, none of which have a tenantId field. The v3 contact table requires tenantId. All 4 would be skipped, so the step was omitted entirely. v3 contacts are populated through normal product usage (newsletter signups, form submissions).
MediaAsset not migrated
The MongoDB Media collection (5,048 documents) stores images as Azure Blob Storage URLs (https://lmsamediaprodeastasia.blob.core.windows.net/...). The v3 media_asset model’s source field is a string enum accepting only "pixabay" and "unsplash". There is no "upload" or "custom" variant in the v3 schema, and the old Azure Blob endpoint is a different storage provider than v3’s DigitalOcean Spaces. Migrating these URLs would produce rows with invalid source values pointing to a decommissioned storage bucket. The step was omitted.
Lead contact data quality
The MongoDB emails[] and phones[] arrays on Lead documents often contain legacy numeric ID strings ("1", "2") rather than real contact values. The map filters these out using format validation — emails must contain @ and .; phones must have ≥ 7 digits after stripping non-numeric characters. Contacts that fail these checks are silently dropped rather than creating invalid rows.
Phase 5 skip summary
| Step | Key | Migrated | Skipped | Reason |
|---|---|---|---|---|
| 17 | blog-post | 1,837 | 247 | Orphaned tenants |
| 18 | social-post | 717 | 0 | — |
Blog post skips (247 out of 2,084)
Blog posts whose tenantId does not exist in the migrated tenant table. Same root cause as subscription and lead skips — the parent tenant was deleted from MongoDB after the content was created.
activityId made nullable (schema change)
BlogPost.activityId and SocialPost.activityId were String @unique (required). Changed to String? @unique with onDelete: SetNull before Phase 5. Migrated posts have activityId: null. This also allows future DM-uploaded posts to exist without an agent activity.
v2 Activity collection not migrated
The MongoDB Activity collection (37,209 docs) is a task-tracker (title, dueDate, status: “To Do” / “In Progress” / “Done”). It has no agentQueue, deliverableType, or inputPayload — it is architecturally different from the v3 Activity content workflow model. Migrating it would create 37K isManual: true task rows that would clutter every client’s v3 activity list. The collection was intentionally omitted.
Blog post content stored as HTML
v2 Post.content is HTML. v3 blog_post.content is @db.Text labelled “Markdown” in comments. The HTML is stored as-is. Rendering in the v3 UI may display raw HTML tags until the content is regenerated or converted.
Phase 6 and beyond
The backlink step remains disabled in migration.config.ts. Enable it and author a map file when ready. The recommended process:
- Set
enabled: truefor the new step. - Run
migrate:dry --only <key>to verify mapping output without writing. - Run
migrate --only <key>against a staging database. - Review the summary counts and any logged skips before running against production.