Map Files
Annotated template
Every map file in config/maps/ exports a single named map object. Below is a fully annotated template showing all available fields.
import type { CollectionMap, MigrationContext } from "../../lib/types";
import type { Db } from "mongodb";
import type { PrismaClient } from "@leadmetrics/db";
// Shape of one document as it comes out of MongoDB.
// Define only the fields you actually use.
interface SourceDoc {
_id: string | { _id: string; name: string }; // can be a plain string or an object
name: string;
tenantId: string;
// ...
}
// Shape of one row as Prisma expects it for the target table.
interface TargetRow {
id: string;
name: string;
tenantId: string;
}
export const map: CollectionMap<SourceDoc, TargetRow> = {
// MongoDB collection to read from.
sourceCollection: "MyCollection",
// Label shown in progress output.
description: "My collection → my_table",
// Optional: override the global batch size (default 500) for this step.
batchSize: 200,
// Optional: runs once before iteration.
// Use this to load lookup tables, pre-hash values, or cache valid IDs.
async setup(db: PrismaClient, mongoDb: Db, ctx: MigrationContext) {
// Example: load all valid tenant IDs into context so mapDoc can validate FKs.
const tenants = await db.tenant.findMany({ select: { id: true } });
ctx.validTenantIds = new Set(tenants.map((t) => t.id));
},
// Maps one MongoDB document to one Prisma row.
// Return null to skip the document. Null returns are counted as "skipped".
mapDoc(doc: SourceDoc, ctx: MigrationContext): TargetRow | null {
const id = toIdStr(doc._id); // handles string | { _id: string } safely
// Skip documents that reference a tenant that was not migrated.
if (!ctx.validTenantIds?.has(doc.tenantId)) {
return null;
}
return {
id,
name: doc.name,
tenantId: doc.tenantId,
};
},
// Writes one batch of mapped rows to the target table.
// Skipped (null-returning) documents are not included in `rows`.
async write(rows: TargetRow[], db: PrismaClient, ctx: MigrationContext) {
await db.myTable.createMany({ data: rows, skipDuplicates: true });
},
// Optional: additional writers for other target tables.
relatedWriters: [
// See the relatedWriter section below.
],
};The relatedWriter pattern
A RelatedWriter handles target tables that should be populated from the same source documents but need their own mapping logic. Unlike mapDoc + write, a relatedWriter receives the full raw batch — including documents that mapDoc returned null for — so it can decide independently which documents to use.
import type { RelatedWriter } from "../../lib/types";
const accountWriter: RelatedWriter<SourceDoc> = {
description: "user → account",
async write(rawDocs: SourceDoc[], db: PrismaClient, ctx: MigrationContext) {
const rows = rawDocs
.map((doc) => {
const id = toIdStr(doc._id);
return {
id: `acc_${id}`,
userId: id,
provider: "credential",
providerAccountId: doc.email,
};
})
.filter(Boolean);
await db.account.createMany({ data: rows, skipDuplicates: true });
},
};
// Then include it in the map:
export const map: CollectionMap<SourceDoc, TargetRow> = {
// ...
relatedWriters: [accountWriter],
};The setup() pattern
setup() is useful whenever mapDoc needs data that would otherwise require a database query per document. The canonical example is the subscription step, which needs to resolve every planInfo object to a Prisma plan.id.
async setup(db: PrismaClient, mongoDb: Db, ctx: MigrationContext) {
// Build a lookup: "regionCode:planSlug" → planId
const plans = await db.plan.findMany({
select: { id: true, slug: true, region: { select: { code: true } } },
});
ctx.planLookup = new Map(
plans.map((p) => [`${p.region.code}:${p.slug}`, p.id])
);
}Then in mapDoc:
mapDoc(doc, ctx) {
const key = `${doc.planInfo?.regionCode}:${doc.planInfo?.planSlug}`;
const planId = ctx.planLookup?.get(key);
if (!planId) return null; // unresolvable plan — skip
return { /* ... */ planId };
}Tips
Returning null to skip
mapDoc should return null for any document that cannot be cleanly mapped. Common reasons: a required FK is missing (tenant was deleted), a lookup key resolves to undefined, or the document has corrupt data. Null returns are counted in the “skipped” column of the summary — they are not errors and do not abort the step.
Validating FKs before insert
Load valid IDs in setup() and check them in mapDoc. This prevents FK constraint errors at write time, which would abort the entire batch. For the member step this is especially important: both the userId and the tenantId must exist in their respective tables before a tenant_member row can be inserted.
if (!ctx.validUserIds?.has(userId) || !ctx.validTenantIds?.has(tenantId)) {
return null;
}toIdStr() — handling mixed _id fields
Some MongoDB collections store _id as a plain string; others store it as a nested object { _id: string; name: string }. A small helper normalises both cases:
function toIdStr(raw: string | { _id: string } | undefined): string {
if (!raw) return "";
if (typeof raw === "string") return raw;
return raw._id;
}Use toIdStr() on any field that might be either form. The offering, tenant, and user collections all exhibit this pattern in their relational reference fields (e.g. assignedTo, createdBy).