Why We Built a Re-Runnable LMS Exporter (And Why You Should Too)
Every migration we've inherited from another vendor has had the same bug: it was a one-shot script. The source platform kept selling courses during the migration window and 200 last-minute purchases got dropped. Idempotency is the cheapest insurance you can write.
The problem with one-shot exports
A migration project takes 3–4 weeks. During those weeks, the source platform doesn't stop. New users register, new enrollments are purchased, refunds are issued, profiles are updated. If your exporter runs once at week one and your importer runs once at week four, every change that happened in between is lost. The standard 'solution' is to manually patch the deltas after cutover, which is precisely how data goes missing — quietly, in the gap between two scripts that each looked successful.
What 'idempotent' means here
Running the exporter ten times against the same source produces the same output. Running the importer ten times against the same export produces the same target state. Enrollment records keyed by `(thinkific_course_id, thinkific_user_id)` are upserted, not inserted — second pass on the same pair updates the existing record instead of duplicating it. Files are written atomically (temp file + rename) so a failed run doesn't leave half-written JSON the next run trips over. This is engineering hygiene, not magic, but it's the difference between a clean cutover and a Tuesday-night incident.
The delta-sync pattern
Once your exporter is re-runnable, the cutover plan becomes: run the full export the night before launch, run the importer that night, then re-run the exporter and importer in delta mode at the moment of cutover the next morning. Anything that changed in those 8 hours rolls in cleanly. The client gets a true point-in-time copy with no manual patching. We migrated a music education platform with 6.5 years of enrollment history and not a single record was missed in the final cutover — because we ran the same scripts twice.
Source-of-truth discipline
The exporter never mutates the source. Ever. This sounds obvious but you'd be surprised. Some 'migration tools' update tags or custom fields on the source platform to track what's been migrated — fine until a client decides to roll back, or the source platform gets sold mid-migration. Our rule is uncompromising: the source is read-only. State that needs persisting (which records have been imported, which have errored) lives in the importer's database, never written back upstream.
Why we made the exporter LMS-agnostic
The same Thinkific exporter we built for our music education client could feed a LifterLMS importer or a Tutor LMS importer tomorrow — because the output format is documented in SPEC.md and doesn't encode LearnDash assumptions. Every destination-specific decision (quiz placement, lesson type mapping, user merge rules) lives in the importer. This isn't theoretical reuse — when a future client asks for Thinkific → Sensei, we don't rewrite the half that talks to Thinkific. We write only the half that talks to Sensei.
What this costs
Building re-runnability is roughly 15% additional engineering effort on the exporter — primarily atomic writes, ID normalization, and state persistence. It pays for itself the first time the export crashes at 80% and you can resume instead of restart. Every migration after that, you ship faster. After six client projects, our exporter is a stable, well-tested asset; we spend our project hours on importer customization and edge cases, not on rebuilding the same data pipeline.