|
1 | 1 | # MobX-RESTful-migrator |
2 | | -Data Migration framework based on MobX-RESTful |
| 2 | + |
| 3 | +Data Migration framework based on [MobX-RESTful][1] |
| 4 | + |
| 5 | +## Overview |
| 6 | + |
| 7 | +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. |
| 8 | + |
| 9 | +## Features |
| 10 | + |
| 11 | +- **Flexible Field Mappings**: Support for four different mapping types |
| 12 | +- **Async Generator Pattern**: Control migration flow at your own pace |
| 13 | +- **Cross-table Relationships**: Handle complex data relationships |
| 14 | +- **Event-Driven Architecture**: Built-in console logging with customizable event bus |
| 15 | +- **TypeScript Support**: Full TypeScript support with type safety |
| 16 | + |
| 17 | +## Installation |
| 18 | + |
| 19 | +```bash |
| 20 | +npm install mobx-restful mobx-restful-migrator |
| 21 | +``` |
| 22 | + |
| 23 | +## Usage Example: Article migration |
| 24 | + |
| 25 | +The typical use case is migrating Article data with the following schema: |
| 26 | + |
| 27 | +- **Source**: Article table with Title, Keywords, Content, Author, Email fields |
| 28 | +- **Target**: Keywords field splits into Category string & Tags array, Author & Email fields map to User table |
| 29 | + |
| 30 | +### Source Data Schema |
| 31 | + |
| 32 | +```typescript |
| 33 | +interface SourceArticle { |
| 34 | + id: number; |
| 35 | + title: string; |
| 36 | + subtitle: string; |
| 37 | + keywords: string; // comma-separated keywords to split into category & tags |
| 38 | + content: string; |
| 39 | + author: string; // maps to User table "name" field |
| 40 | + email: string; // maps to User table "email" field |
| 41 | +} |
| 42 | +``` |
| 43 | + |
| 44 | +### Target Models |
| 45 | + |
| 46 | +```typescript |
| 47 | +import { HTTPClient } from 'koajax'; |
| 48 | +import { ListModel, DataObject, Filter, IDType, toggle } from 'mobx-restful'; |
| 49 | +import { buildURLData } from 'web-utility'; |
| 50 | + |
| 51 | +export abstract class TableModel< |
| 52 | + D extends DataObject, |
| 53 | + F extends Filter<D> = Filter<D> |
| 54 | +> extends ListModel<D, F> { |
| 55 | + client = new HTTPClient({ baseURI: 'http://localhost:8080', responseType: 'json' }); |
| 56 | + |
| 57 | + @toggle('uploading') |
| 58 | + async updateOne(data: Filter<D>, id?: IDType) { |
| 59 | + const { body } = await (id |
| 60 | + ? this.client.put<D>(`${this.baseURI}/${id}`, data) |
| 61 | + : this.client.post<D>(this.baseURI, data)); |
| 62 | + |
| 63 | + return (this.currentOne = body!); |
| 64 | + } |
| 65 | + |
| 66 | + async loadPage(pageIndex: number, pageSize: number, filter: F) { |
| 67 | + const { body } = await this.client.get<{ list: D[]; count: number }>( |
| 68 | + `${this.baseURI}?${buildURLData({ ...filter, pageIndex, pageSize })}` |
| 69 | + ); |
| 70 | + return { pageData: body!.list, totalCount: body!.count }; |
| 71 | + } |
| 72 | +} |
| 73 | + |
| 74 | +export interface User { |
| 75 | + id: number; |
| 76 | + name: string; |
| 77 | + email?: string; |
| 78 | +} |
| 79 | + |
| 80 | +export interface Article { |
| 81 | + id: number; |
| 82 | + title: string; |
| 83 | + category: string; |
| 84 | + tags: string[]; |
| 85 | + content: string; |
| 86 | + author: User; |
| 87 | +} |
| 88 | + |
| 89 | +export class UserModel extends TableModel<User> { |
| 90 | + baseURI = '/users'; |
| 91 | +} |
| 92 | + |
| 93 | +export class ArticleModel extends TableModel<Article> { |
| 94 | + baseURI = '/articles'; |
| 95 | +} |
| 96 | +``` |
| 97 | + |
| 98 | +### Migration Configuration |
| 99 | + |
| 100 | +First, export your CSV data file `articles.csv` from an Excel file or Old database: |
| 101 | + |
| 102 | +```csv |
| 103 | +title,subtitle,keywords,content,author,email |
| 104 | +Introduction to TypeScript,A Comprehensive Guide,"typescript,javascript,programming","TypeScript is a typed superset of JavaScript...",John Doe,[email protected] |
| 105 | +MobX State Management,Made Simple,"mobx,react,state-management","MobX makes state management simple...",Jane Smith,[email protected] |
| 106 | +``` |
| 107 | + |
| 108 | +Then implement the migration: |
| 109 | + |
| 110 | +```typescript |
| 111 | +#! /usr/bin/env tsx |
| 112 | + |
| 113 | +import { RestMigrator, MigrationSchema, ConsoleLogger } from 'mobx-restful-migrator'; |
| 114 | +import { FileHandle, open } from 'fs/promises'; |
| 115 | +import { readTextTable } from 'web-utility'; |
| 116 | + |
| 117 | +import { SourceArticle, Article, ArticleModel, UserModel } from './source'; |
| 118 | + |
| 119 | +// Load and parse CSV data using async streaming for large files |
| 120 | +async function* readCSV<T extends object>(path: string) { |
| 121 | + let fileHandle: FileHandle | undefined; |
| 122 | + |
| 123 | + try { |
| 124 | + fileHandle = await open(path); |
| 125 | + |
| 126 | + const stream = fileHandle.createReadStream({ encoding: 'utf-8' }); |
| 127 | + |
| 128 | + yield* readTextTable<T>(stream, true) as AsyncGenerator<T>; |
| 129 | + } finally { |
| 130 | + await fileHandle?.close(); |
| 131 | + } |
| 132 | +} |
| 133 | + |
| 134 | +const loadSourceArticles = () => readCSV<SourceArticle>('article.csv'); |
| 135 | + |
| 136 | +// Complete migration configuration demonstrating all 4 mapping types |
| 137 | +const mapping: MigrationSchema<SourceArticle, Article> = { |
| 138 | + |
| 139 | + // 1. Many-to-One mapping: Title + Subtitle → combined title |
| 140 | + title: ({ title, subtitle }) => ({ |
| 141 | + title: { value: `${title}: ${subtitle}` }, |
| 142 | + }), |
| 143 | + content: 'content', |
| 144 | + |
| 145 | + // 2. One-to-Many mapping: Keywords string → category string & tags array |
| 146 | + keywords: ({ keywords }) => { |
| 147 | + const [category, ...tags] = keywords.split(',').map(tag => tag.trim()); |
| 148 | + |
| 149 | + return { category: { value: category }, tags: { value: tags } }; |
| 150 | + }, |
| 151 | + // 3. Cross-table relationship: Author & Email → User table |
| 152 | + author: ({ author, email }) => ({ |
| 153 | + author: { |
| 154 | + value: { name: author, email }, |
| 155 | + model: UserModel, // Maps to User table via ListModel |
| 156 | + }, |
| 157 | + }), |
| 158 | +}; |
| 159 | + |
| 160 | +// Run migration with built-in console logging (default) |
| 161 | +const migrator = new RestMigrator(loadSourceArticles, ArticleModel, mapping); |
| 162 | + |
| 163 | +// The ConsoleLogger automatically logs each step: |
| 164 | +// - saved No.X: successful migrations with source, mapped, and target data |
| 165 | +// - skipped No.X: skipped items (duplicate unique fields) |
| 166 | +// - error at No.X: migration errors with details |
| 167 | + |
| 168 | +for await (const { title } of migrator.boot()) { |
| 169 | + // Process the migrated target objects |
| 170 | + console.log(`Successfully migrated article: ${title}`); |
| 171 | +} |
| 172 | +``` |
| 173 | + |
| 174 | +In the end, run your script with a TypeScript runtime: |
| 175 | + |
| 176 | +```bash |
| 177 | +tsx your-migration.ts 1> saved.log 2> error.log |
| 178 | +``` |
| 179 | + |
| 180 | +### Optional: Use custom event bus |
| 181 | + |
| 182 | +```typescript |
| 183 | +class CustomEventBus implements MigrationEventBus<SourceArticle, Article> { |
| 184 | + async save({ index, targetItem }) { |
| 185 | + console.info(`✅ Migrated article ${index}: ${targetItem?.title}`); |
| 186 | + } |
| 187 | + |
| 188 | + async skip({ index, error }) { |
| 189 | + console.warn(`⚠️ Skipped article ${index}: ${error?.message}`); |
| 190 | + } |
| 191 | + |
| 192 | + async error({ index, error }) { |
| 193 | + console.error(`❌ Error at article ${index}: ${error?.message}`); |
| 194 | + } |
| 195 | +} |
| 196 | + |
| 197 | +const migratorWithCustomLogger = new RestMigrator( |
| 198 | + loadSourceArticles, |
| 199 | + ArticleModel, |
| 200 | + mapping, |
| 201 | + new CustomEventBus() |
| 202 | +); |
| 203 | +``` |
| 204 | + |
| 205 | +## Four Mapping Types |
| 206 | + |
| 207 | +### 1. Simple 1-to-1 Mapping |
| 208 | + |
| 209 | +Map source field directly to target field using string mapping: |
| 210 | + |
| 211 | +```typescript |
| 212 | +const mapping: MigrationSchema<SourceArticle, Article> = { |
| 213 | + title: 'title', |
| 214 | + content: 'content', |
| 215 | +}; |
| 216 | +``` |
| 217 | + |
| 218 | +### 2. Many-to-One Mapping |
| 219 | + |
| 220 | +Use resolver function to combine multiple source fields into one target field: |
| 221 | + |
| 222 | +```typescript |
| 223 | +const mapping: MigrationSchema<SourceArticle, Article> = { |
| 224 | + title: ({ title, subtitle }) => ({ |
| 225 | + title: { value: `${title}: ${subtitle}` }, |
| 226 | + }), |
| 227 | +}; |
| 228 | +``` |
| 229 | + |
| 230 | +### 3. One-to-Many Mapping |
| 231 | + |
| 232 | +Use resolver function to map one source field to multiple target fields with `value` property: |
| 233 | + |
| 234 | +```typescript |
| 235 | +const mapping: MigrationSchema<SourceArticle, Article> = { |
| 236 | + keywords: ({ keywords }) => { |
| 237 | + const [category, ...tags] = keywords.split(',').map(tag => tag.trim()); |
| 238 | + |
| 239 | + return { category: { value: category }, tags: { value: tags } }; |
| 240 | + }, |
| 241 | +}; |
| 242 | +``` |
| 243 | + |
| 244 | +### 4. Cross-Table Relationships |
| 245 | + |
| 246 | +Use resolver function with `model` property for related tables: |
| 247 | + |
| 248 | +```typescript |
| 249 | +const mapping: MigrationSchema<SourceArticle, Article> = { |
| 250 | + author: ({ author, email }) => ({ |
| 251 | + author: { |
| 252 | + value: { name: author, email }, |
| 253 | + model: UserModel, // References User ListModel |
| 254 | + }, |
| 255 | + }), |
| 256 | +}; |
| 257 | +``` |
| 258 | + |
| 259 | +## Event-Driven Migration Architecture |
| 260 | + |
| 261 | +The migrator includes a built-in Event Bus for monitoring and controlling the migration process: |
| 262 | + |
| 263 | +### Built-in Console Logging |
| 264 | + |
| 265 | +By default, RestMigrator uses the `ConsoleLogger` which provides detailed console output: |
| 266 | + |
| 267 | +```typescript |
| 268 | +import { RestMigrator, ConsoleLogger } from 'mobx-restful-migrator'; |
| 269 | + |
| 270 | +import { loadSourceArticles, ArticleModel, mapping } from './source'; |
| 271 | + |
| 272 | +// ConsoleLogger is used by default |
| 273 | +const migrator = new RestMigrator(loadSourceArticles, ArticleModel, mapping); |
| 274 | + |
| 275 | +for await (const { title } of migrator.boot()) { |
| 276 | + // Console automatically shows: |
| 277 | + // - saved No.X with source, mapped, and target data tables |
| 278 | + // - skipped No.X for duplicate unique fields |
| 279 | + // - error at No.X for migration errors |
| 280 | + |
| 281 | + // Your processing logic here |
| 282 | + console.log(`✅ Article migrated: ${title}`); |
| 283 | +} |
| 284 | +``` |
| 285 | + |
| 286 | +### Custom Event Handling |
| 287 | + |
| 288 | +Implement your own Event Bus for custom logging and monitoring: |
| 289 | + |
| 290 | +```typescript |
| 291 | +import { MigrationEventBus, MigrationProgress } from 'mobx-restful-migrator'; |
| 292 | +import { outputJSON } from 'fs-extra'; |
| 293 | + |
| 294 | +import { SourceArticle, Article, loadSourceArticles, ArticleModel, mapping } from './source'; |
| 295 | + |
| 296 | +class FileLogger implements MigrationEventBus<SourceArticle, Article> { |
| 297 | + bootedAt = new Date().toJSON(); |
| 298 | + |
| 299 | + async save({ index, sourceItem, targetItem }: MigrationProgress<SourceArticle, Article>) { |
| 300 | + // Log to file, send notifications, etc. |
| 301 | + await outputJSON(`logs/save-${this.bootedAt}.json`, { |
| 302 | + type: 'success', |
| 303 | + index, |
| 304 | + sourceId: sourceItem?.id, |
| 305 | + targetId: targetItem?.id, |
| 306 | + savedAt: new Date().toJSON(), |
| 307 | + }); |
| 308 | + } |
| 309 | + |
| 310 | + async skip({ index, sourceItem, error }: MigrationProgress<SourceArticle, Article>) { |
| 311 | + await outputJSON(`logs/skip-${this.bootedAt}.json`, { |
| 312 | + type: 'skipped', |
| 313 | + index, |
| 314 | + sourceId: sourceItem?.id, |
| 315 | + error: error?.message, |
| 316 | + skippedAt: new Date().toJSON(), |
| 317 | + }); |
| 318 | + } |
| 319 | + |
| 320 | + async error({ index, sourceItem, error }: MigrationProgress<SourceArticle, Article>) { |
| 321 | + await outputJSON(`logs/error-${this.bootedAt}.json`, { |
| 322 | + type: 'error', |
| 323 | + index, |
| 324 | + sourceId: sourceItem?.id, |
| 325 | + error: error?.message, |
| 326 | + errorAt: new Date().toJSON(), |
| 327 | + }); |
| 328 | + } |
| 329 | +} |
| 330 | + |
| 331 | +const migrator = new RestMigrator(loadSourceArticles, ArticleModel, mapping, new FileLogger()); |
| 332 | +``` |
| 333 | + |
| 334 | +[1]: https://github.com/idea2app/MobX-RESTful |
0 commit comments