Data Migration framework based on MobX-RESTful
MobX-RESTful-migrator is a TypeScript library that provides a flexible data migration framework built on top of MobX-RESTful's ListModel abstraction. It allows you to migrate data from various sources through MobX-RESTful models with customizable field mappings and relationships.
- Flexible Field Mappings: Support for four different mapping types
- Async Generator Pattern: Control migration flow at your own pace
- Cross-table Relationships: Handle complex data relationships
- Event-Driven Architecture: Built-in console logging with customizable event bus
- TypeScript Support: Full TypeScript support with type safety
npm install mobx-restful mobx-restful-migrator
The typical use case is migrating Article data with the following schema:
- Source: Article table with Title, Keywords, Content, Author, Email fields
- Target: Keywords field splits into Category string & Tags array, Author & Email fields map to User table
interface SourceArticle {
id: number;
title: string;
subtitle: string;
keywords: string; // comma-separated keywords to split into category & tags
content: string;
author: string; // maps to User table "name" field
email: string; // maps to User table "email" field
}
import { HTTPClient } from 'koajax';
import { ListModel, DataObject, Filter, IDType, toggle } from 'mobx-restful';
import { buildURLData } from 'web-utility';
export abstract class TableModel<
D extends DataObject,
F extends Filter<D> = Filter<D>,
> extends ListModel<D, F> {
client = new HTTPClient({ baseURI: 'http://localhost:8080', responseType: 'json' });
@toggle('uploading')
async updateOne(data: Filter<D>, id?: IDType) {
const { body } = await (id
? this.client.put<D>(`${this.baseURI}/${id}`, data)
: this.client.post<D>(this.baseURI, data));
return (this.currentOne = body!);
}
async loadPage(pageIndex: number, pageSize: number, filter: F) {
const { body } = await this.client.get<{ list: D[]; count: number }>(
`${this.baseURI}?${buildURLData({ ...filter, pageIndex, pageSize })}`,
);
return { pageData: body!.list, totalCount: body!.count };
}
}
export interface User {
id: number;
name: string;
email?: string;
}
export interface Article {
id: number;
title: string;
category: string;
tags: string[];
content: string;
author: User;
}
export class UserModel extends TableModel<User> {
baseURI = '/users';
}
export class ArticleModel extends TableModel<Article> {
baseURI = '/articles';
}
First, export your CSV data file articles.csv
from an Excel file or Old database:
title,subtitle,keywords,content,author,email
Introduction to TypeScript,A Comprehensive Guide,"typescript,javascript,programming","TypeScript is a typed superset of JavaScript...",John Doe,[email protected]
MobX State Management,Made Simple,"mobx,react,state-management","MobX makes state management simple...",Jane Smith,[email protected]
Then implement the migration:
#! /usr/bin/env tsx
import { RestMigrator, MigrationSchema, ConsoleLogger } from 'mobx-restful-migrator';
import { FileHandle, open } from 'fs/promises';
import { readTextTable } from 'web-utility';
import { SourceArticle, Article, ArticleModel, UserModel } from './source';
// Load and parse CSV data using async streaming for large files
async function* readCSV<T extends object>(path: string) {
let fileHandle: FileHandle | undefined;
try {
fileHandle = await open(path);
const stream = fileHandle.createReadStream({ encoding: 'utf-8' });
yield* readTextTable<T>(stream, true) as AsyncGenerator<T>;
} finally {
await fileHandle?.close();
}
}
const loadSourceArticles = () => readCSV<SourceArticle>('article.csv');
// Complete migration configuration demonstrating all 4 mapping types
const mapping: MigrationSchema<SourceArticle, Article> = {
// 1. Many-to-One mapping: Title + Subtitle → combined title
title: ({ title, subtitle }) => ({
title: { value: `${title}: ${subtitle}` },
}),
content: 'content',
// 2. One-to-Many mapping: Keywords string → category string & tags array
keywords: ({ keywords }) => {
const [category, ...tags] = keywords.split(',').map(tag => tag.trim());
return { category: { value: category }, tags: { value: tags } };
},
// 3. Cross-table relationship: Author & Email → User table
author: ({ author, email }) => ({
author: {
value: { name: author, email },
model: UserModel, // Maps to User table via ListModel
},
}),
};
// Run migration with built-in console logging (default)
const migrator = new RestMigrator(loadSourceArticles, ArticleModel, mapping);
// The ConsoleLogger automatically logs each step:
// - saved No.X: successful migrations with source, mapped, and target data
// - skipped No.X: skipped items (duplicate unique fields)
// - error at No.X: migration errors with details
for await (const { title } of migrator.boot()) {
// Process the migrated target objects
console.log(`Successfully migrated article: ${title}`);
}
In the end, run your script with a TypeScript runtime:
tsx your-migration.ts 1> saved.log 2> error.log
class CustomEventBus implements MigrationEventBus<SourceArticle, Article> {
async save({ index, targetItem }) {
console.info(`✅ Migrated article ${index}: ${targetItem?.title}`);
}
async skip({ index, error }) {
console.warn(`⚠️ Skipped article ${index}: ${error?.message}`);
}
async error({ index, error }) {
console.error(`❌ Error at article ${index}: ${error?.message}`);
}
}
const migratorWithCustomLogger = new RestMigrator(
loadSourceArticles,
ArticleModel,
mapping,
new CustomEventBus(),
);
Map source field directly to target field using string mapping:
const mapping: MigrationSchema<SourceArticle, Article> = {
title: 'title',
content: 'content',
};
Use resolver function to combine multiple source fields into one target field:
const mapping: MigrationSchema<SourceArticle, Article> = {
title: ({ title, subtitle }) => ({
title: { value: `${title}: ${subtitle}` },
}),
};
Use resolver function to map one source field to multiple target fields with value
property:
const mapping: MigrationSchema<SourceArticle, Article> = {
keywords: ({ keywords }) => {
const [category, ...tags] = keywords.split(',').map(tag => tag.trim());
return { category: { value: category }, tags: { value: tags } };
},
};
Use resolver function with model
property for related tables:
const mapping: MigrationSchema<SourceArticle, Article> = {
author: ({ author, email }) => ({
author: {
value: { name: author, email },
model: UserModel, // References User ListModel
},
}),
};
The migrator includes a built-in Event Bus for monitoring and controlling the migration process:
By default, RestMigrator uses the ConsoleLogger
which provides detailed console output:
import { RestMigrator, ConsoleLogger } from 'mobx-restful-migrator';
import { loadSourceArticles, ArticleModel, mapping } from './source';
// ConsoleLogger is used by default
const migrator = new RestMigrator(loadSourceArticles, ArticleModel, mapping);
for await (const { title } of migrator.boot()) {
// Console automatically shows:
// - saved No.X with source, mapped, and target data tables
// - skipped No.X for duplicate unique fields
// - error at No.X for migration errors
// Your processing logic here
console.log(`✅ Article migrated: ${title}`);
}
Implement your own Event Bus for custom logging and monitoring:
import { MigrationEventBus, MigrationProgress } from 'mobx-restful-migrator';
import { outputJSON } from 'fs-extra';
import { SourceArticle, Article, loadSourceArticles, ArticleModel, mapping } from './source';
class FileLogger implements MigrationEventBus<SourceArticle, Article> {
bootedAt = new Date().toJSON();
async save({ index, sourceItem, targetItem }: MigrationProgress<SourceArticle, Article>) {
// Log to file, send notifications, etc.
await outputJSON(`logs/save-${this.bootedAt}.json`, {
type: 'success',
index,
sourceId: sourceItem?.id,
targetId: targetItem?.id,
savedAt: new Date().toJSON(),
});
}
async skip({ index, sourceItem, error }: MigrationProgress<SourceArticle, Article>) {
await outputJSON(`logs/skip-${this.bootedAt}.json`, {
type: 'skipped',
index,
sourceId: sourceItem?.id,
error: error?.message,
skippedAt: new Date().toJSON(),
});
}
async error({ index, sourceItem, error }: MigrationProgress<SourceArticle, Article>) {
await outputJSON(`logs/error-${this.bootedAt}.json`, {
type: 'error',
index,
sourceId: sourceItem?.id,
error: error?.message,
errorAt: new Date().toJSON(),
});
}
}
const migrator = new RestMigrator(loadSourceArticles, ArticleModel, mapping, new FileLogger());