diff --git a/extensions/saas/sources/api/src/main/java/tools/dynamia/modules/saas/api/AccountExportIgnore.java b/extensions/saas/sources/api/src/main/java/tools/dynamia/modules/saas/api/AccountExportIgnore.java
new file mode 100644
index 00000000..d2988a03
--- /dev/null
+++ b/extensions/saas/sources/api/src/main/java/tools/dynamia/modules/saas/api/AccountExportIgnore.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package tools.dynamia.modules.saas.api;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Marks a JPA {@code @Entity} class to be completely excluded from the Tenant Mobility
+ * export/import/clone pipeline.
+ *
+ *
Apply this annotation to entities that contain ephemeral, audit, cache or metric data
+ * that should never travel with a tenant:
+ *
+ *
{@code
+ * @Entity
+ * @AccountExportIgnore
+ * public class LoginAuditLog extends SimpleEntitySaaS {
+ * ...
+ * }
+ * }
+ *
+ * Entities annotated with {@code @AccountExportIgnore} are silently skipped during
+ * discovery, so they will never appear in an export file and will never be processed
+ * on import.
+ *
+ * @author Mario Serrano Leones
+ * @see ExportIgnore
+ */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+public @interface AccountExportIgnore {
+}
+
diff --git a/extensions/saas/sources/api/src/main/java/tools/dynamia/modules/saas/api/ExportIgnore.java b/extensions/saas/sources/api/src/main/java/tools/dynamia/modules/saas/api/ExportIgnore.java
new file mode 100644
index 00000000..a143dc1d
--- /dev/null
+++ b/extensions/saas/sources/api/src/main/java/tools/dynamia/modules/saas/api/ExportIgnore.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package tools.dynamia.modules.saas.api;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Marks a specific field in a JPA entity to be excluded from Tenant Mobility
+ * serialization during export.
+ *
+ *
Use this for fields that are computed, cached, or otherwise should not be
+ * persisted to a different environment:
+ *
+ *
{@code
+ * @Entity
+ * public class Customer extends SimpleEntitySaaS {
+ *
+ * private String name;
+ *
+ * @ExportIgnore
+ * private transient String cachedFullName; // computed
+ *
+ * @ExportIgnore
+ * private String internalToken; // environment-specific secret
+ * }
+ * }
+ *
+ * Fields with this annotation are silently skipped during entity serialization.
+ * On import, those fields will retain their default (null / primitive default) values.
+ *
+ * @author Mario Serrano Leones
+ * @see AccountExportIgnore
+ */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface ExportIgnore {
+}
+
diff --git a/extensions/saas/sources/migration/ARCHITECTURE.md b/extensions/saas/sources/migration/ARCHITECTURE.md
new file mode 100644
index 00000000..6ecb2e6a
--- /dev/null
+++ b/extensions/saas/sources/migration/ARCHITECTURE.md
@@ -0,0 +1,338 @@
+# Account Migration Module — Architecture
+
+## 1. Overview
+
+The Account Migration Module enables full lifecycle management of tenant (Account) data: export, import, clone, backup, and restore. It is designed for:
+
+- **Millions of rows** — streaming, never loads all data into memory.
+- **Database independence** — uses JPA/Hibernate metamodel exclusively.
+- **Extensibility** — SPI interfaces for identity mapping, progress tracking, and cancellation.
+- **Non-blocking** — every operation runs as an async background job (Java virtual threads).
+
+---
+
+## 2. Component Map
+
+```
+┌──────────────────────────────────────────────────────────────────────┐
+│ AccountMigrationController (REST) │
+│ POST /export POST /import POST /clone GET /jobs/{jobId} GET /download │
+└─────────────────────────────┬────────────────────────────────────────┘
+ │ delegates to
+┌─────────────────────────────▼────────────────────────────────────────┐
+│ AccountMigrationJobService │
+│ createJob() · cancelJob() · getJob() · listJobs() │
+│ Persists AccountMigrationJob entity in DB │
+└──────┬───────────────────────────────────────────┬───────────────────┘
+ │ launches via │ saves progress via
+ │ SchedulerUtil.runWithResult(worker) │ CrudService.update()
+ ▼ │
+┌─────────────────────────┐ │
+│ Workers (VirtualThread) │ │
+│ ┌──────────────────────┴─┐ │
+│ │ ExportWorker │ │
+│ │ ImportWorker │◄────────────────────┘
+│ │ CloneWorker │
+│ └────────────┬───────────┘
+│ │ calls
+│ ┌────────────▼───────────┐
+│ │ AccountMigrationService │
+│ │ (impl: coordinates │
+│ │ pipelines) │
+│ └────────────┬───────────┘
+└───────────────┼───────────────────────────────────────────────────────
+ │
+ ┌───────────┴────────────┐
+ │ │
+ ▼ ▼
+┌──────────────┐ ┌────────────────┐
+│ ExportPipeline│ │ ImportPipeline │
+│ (streaming │ │ (streaming │
+│ JSON write)│ │ JSON read) │
+└──────┬───────┘ └───────┬────────┘
+ │ │
+ │ uses │ uses
+ ┌──┴──────────────────────┴──┐
+ │ AccountEntityDiscovery │ discovers all @Entity + AccountAware
+ │ EntityDependencyGraph │ topological sort via JPA metamodel
+ │ IdentityMapper SPI │ KEEP_IDS / REGENERATE_IDS
+ └────────────────────────────┘
+```
+
+---
+
+## 3. Entity Discovery
+
+### Algorithm
+
+```
+1. Get all managed entity types: EntityManagerFactory.getMetamodel().getEntities()
+2. For each EntityType:
+ a. If AccountAware.class.isAssignableFrom(T.getJavaType()) → candidate
+ b. If T.getJavaType().isAnnotationPresent(@AccountExportIgnore) → exclude
+3. Always include Account.class (the tenant root)
+4. Return final exportable set
+```
+
+### Dependency Graph (Topological Sort)
+
+Used to determine import order: parents before children.
+
+```
+For each candidate entity E:
+ For each SingularAttribute A in E's metamodel:
+ If A.persistentAttributeType == MANY_TO_ONE or ONE_TO_ONE:
+ If A.javaType is also in the candidate set:
+ Add edge: A.javaType → E (A.javaType must be imported before E)
+
+Run Kahn's BFS algorithm to get topological order.
+```
+
+Example result:
+```
+Account → Customer → Order → OrderItem
+ ↓
+ Product
+```
+
+---
+
+## 4. Export Pipeline
+
+### Streaming Strategy
+
+```
+OutputStream (raw or GZIPOutputStream)
+ └── JsonGenerator (Jackson streaming)
+ ├── Write header: {version, exportedAt, sourceAccountId, identityStrategy}
+ ├── Write "account": AccountDTO object
+ └── Write "entities": {
+ For each entityClass in topological order:
+ Write entityClass.getName(): [
+ LOOP (pagination by chunks):
+ count = CrudService.count(entityClass, {accountId})
+ for page 1..N:
+ chunk = CrudService.find(entityClass, {accountId, paginator})
+ for each record:
+ write as JSON object (flat map, refs as _ref_id)
+ ]
+ }
+```
+
+### Serialization of a Single Entity
+
+```
+For each SingularAttribute in EntityType:
+ BASIC / EMBEDDED → write field value directly
+ MANY_TO_ONE / ONE_TO_ONE → write {fieldName}_ref_id:
+ ONE_TO_MANY / MANY_TO_MANY → SKIP (reconstructed during import via child entities)
+```
+
+Fields annotated with `@ExportIgnore` are skipped.
+
+---
+
+## 5. Import Pipeline
+
+### Streaming Strategy
+
+```
+InputStream (auto-detected: raw or GZIPInputStream)
+ └── JsonParser (Jackson streaming)
+ ├── Read header → validate version, note sourceAccountId
+ ├── Read "account" → AccountDTO (optionally create new Account)
+ └── Read "entities" → {
+ For each entityClassName:
+ resolve class → Class.forName(entityClassName)
+ For each JSON record in array (chunked):
+ deserialize → entity instance
+ resolve _ref_id references via idMappings
+ set accountId = targetAccountId
+ persist entity
+ record originalId → newId in idMappings
+ }
+```
+
+### ID Resolution (Identity Mapper)
+
+```
+KEEP_IDS:
+ newId = originalId
+ ref resolution: use originalRefId as-is
+
+REGENERATE_IDS (default for clone):
+ newId = null → JPA auto-generates
+ after persist: record {originalId → generatedId} in idMappings
+ ref resolution: idMappings[refClass][originalRefId] → resolvedId
+```
+
+---
+
+## 6. Worker Lifecycle
+
+```
+POST /export/{accountId}
+ │
+ ▼
+AccountMigrationJobService.createExportJob(accountId, options)
+ │
+ ├── 1. Persist AccountMigrationJob{status=PENDING}
+ ├── 2. SchedulerUtil.runWithResult(new ExportWorker(jobId, accountId, options))
+ │ └── Virtual Thread starts
+ │ ├── Update job status → RUNNING
+ │ ├── Call ExportPipeline.export(...)
+ │ │ └── MigrationProgressListener updates job.progress periodically
+ │ ├── On success: update job status → COMPLETED, set resultPath
+ │ └── On failure: update job status → FAILED, set errorMessage
+ └── 3. Return jobId to caller (non-blocking)
+
+Cancellation:
+ POST /jobs/{jobId}/cancel
+ ├── Load job from DB
+ ├── Get CancellationToken from in-memory registry
+ ├── token.cancel()
+ └── Worker's main loop checks token.isCancelled() between chunks → exits gracefully
+```
+
+---
+
+## 7. Export File Format
+
+```
+saas_export_42_20260614T100500.json[.gz]
+```
+
+```json
+{
+ "version": "1",
+ "exportedAt": "2026-06-14T10:05:00",
+ "sourceAccountId": 42,
+ "identityStrategy": "KEEP_IDS",
+ "account": {
+ "id": 42,
+ "name": "Acme Corp",
+ "subdomain": "acme",
+ "email": "admin@acme.com",
+ ...
+ },
+ "entities": {
+ "tools.dynamia.modules.saas.jpa.AccountParameter": [
+ { "id": 1, "accountId": 42, "name": "theme", "value": "dark" }
+ ],
+ "com.example.Customer": [
+ { "id": 10, "accountId": 42, "name": "John", "category_ref_id": 3 }
+ ],
+ "com.example.Order": [
+ { "id": 100, "accountId": 42, "customer_ref_id": 10, "total": 99.99 }
+ ]
+ }
+}
+```
+
+**Key conventions:**
+- `{fieldName}_ref_id` encodes a `@ManyToOne` / `@OneToOne` reference by its primary key.
+- The `account` section makes the package self-describing.
+- Entities appear in topological order (parents before children).
+
+---
+
+## 8. Identity Mapping SPI
+
+```java
+public interface IdentityMapper {
+ // Map an original ID to the ID to use when persisting
+ // Return null → let JPA auto-generate
+ Object mapId(Object originalId, Class> entityClass);
+
+ // Resolve a reference ID from the exported file to the actual ID in the target DB
+ Object resolveReferenceId(Object originalRefId, Class> refClass,
+ Map> idMappings);
+
+ IdentityStrategy getStrategy();
+}
+```
+
+Register a custom implementation as a Spring bean (`@Component` / `@Service`) to override the default behaviour for a given strategy. `ImportPipeline` auto-discovers all `IdentityMapper` beans and selects by `getStrategy()` before falling back to the built-in defaults.
+
+### Built-in Implementations
+
+| Class | Strategy | Use Case |
+|-------|----------|---------- |
+| `KeepIdsIdentityMapper` | `KEEP_IDS` | Cross-env restore to empty DB |
+| `RegenerateIdsIdentityMapper` | `REGENERATE_IDS` | Clone within same DB |
+
+> **Note:** `UUID7` is declared in `IdentityStrategy` but not yet implemented. Selecting it throws `MigrationException` (planned for v3).
+
+---
+
+## 9. Annotaions
+
+### `@AccountExportIgnore`
+
+```java
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+public @interface AccountExportIgnore {}
+```
+
+Apply to `@Entity` classes that should never be exported (audit logs, metrics, caches).
+
+### `@ExportIgnore`
+
+```java
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface ExportIgnore {}
+```
+
+Apply to entity fields that should be skipped during serialization (computed values, sensitive tokens, caches).
+
+---
+
+## 10. Database Schema
+
+The module adds one table:
+
+```sql
+CREATE TABLE saas_migration_jobs (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
+ uuid VARCHAR(50) NOT NULL UNIQUE,
+ account_id BIGINT,
+ target_account_id BIGINT,
+ job_type VARCHAR(30),
+ status VARCHAR(30),
+ progress INT DEFAULT 0,
+ progress_message VARCHAR(2000),
+ created_at DATETIME,
+ started_at DATETIME,
+ finished_at DATETIME,
+ error_message TEXT,
+ result_path VARCHAR(1000),
+ options_json VARCHAR(2000)
+);
+```
+
+---
+
+## 11. Scalability Notes
+
+| Concern | Mitigation |
+|---------|------------|
+| Large tables | Chunked pagination (configurable, default 500 rows/page) |
+| Memory | Jackson streaming API: never load all rows |
+| Network / disk | Optional GZIP compression |
+| Long-running jobs | Virtual threads, cooperative cancellation via `CancellationToken` |
+| DB load | Read-only paginated queries; imports batched per chunk |
+| Concurrent jobs | In-memory job registry + DB-backed state; configurable max concurrent |
+
+---
+
+## 12. Implementation Roadmap
+
+| Phase | Scope |
+|-------|-------|
+| **v1 (current)** | EXPORT, IMPORT, CLONE, BACKUP, RESTORE. `KEEP_IDS` + `REGENERATE_IDS`. REST API. Progress tracking. Cancellation. |
+| **v2** | Cross-environment MIGRATE (HTTP push to remote endpoint). Resume after failure (checkpoint in DB). |
+| **v3** | `UUID7` identity strategy. Partial export (subset of entities). Schema validation on import. Diff/merge strategy. |
+| **v4** | Multi-region database migration. S3/GCS file storage backend. Event-driven progress via SSE/WebSocket. |
+
diff --git a/extensions/saas/sources/migration/README.md b/extensions/saas/sources/migration/README.md
new file mode 100644
index 00000000..ceffd010
--- /dev/null
+++ b/extensions/saas/sources/migration/README.md
@@ -0,0 +1,202 @@
+# DynamiaTools SaaS — Tenant Mobility Module
+
+[](https://search.maven.org/search?q=tools.dynamia.modules.saas.migration)
+
+
+The **Tenant Mobility Module** is a sub-module of the SaaS extension that provides full lifecycle management for tenant data: export, import, clone, backup, restore, and cross-environment migration.
+
+All operations run as **async background jobs** via virtual threads, so long-running processes (millions of rows) never block the application.
+
+---
+
+## Features
+
+| Operation | Description |
+|-----------|-------------|
+| `EXPORT` | Serialize all tenant data to a versioned JSON/GZIP file |
+| `IMPORT` | Restore tenant data from a previously exported file |
+| `CLONE` | Duplicate a tenant in the same system (different accountId) |
+| `BACKUP` | Alias for EXPORT tagged as backup |
+| `RESTORE` | Alias for IMPORT that replaces existing data |
+| `MIGRATE` | Cross-environment export + remote import (planned v2) |
+
+---
+
+## Installation
+
+```xml
+
+ tools.dynamia.modules
+ tools.dynamia.modules.saas.migration
+ 26.6.0
+
+```
+
+Make sure your JPA entity scan includes `tools.dynamia.modules.saas.migration` (or let Spring Boot's auto-scan pick it up from the classpath).
+
+---
+
+## Quick Start
+
+### 1. Mark entities to ignore (optional)
+
+```java
+// Suppress entire entity from export/import
+@Entity
+@AccountExportIgnore
+public class LoginAuditLog extends SimpleEntitySaaS { ... }
+
+// Suppress specific fields
+@Entity
+public class Customer extends SimpleEntitySaaS {
+
+ @ExportIgnore
+ private String cachedScore; // computed, do not export
+}
+```
+
+### 2. Launch an async export job via REST
+
+```http
+POST /api/saas/migration/jobs/export/42
+Content-Type: application/json
+
+{
+ "chunkSize": 500,
+ "compressionEnabled": true,
+ "identityStrategy": "KEEP_IDS"
+}
+```
+
+Response:
+```json
+{
+ "jobId": "abc-123",
+ "jobType": "EXPORT",
+ "status": "PENDING",
+ "createdAt": "2026-06-14T10:00:00"
+}
+```
+
+### 3. Poll job status
+
+```http
+GET /api/saas/migration/jobs/abc-123
+```
+
+### 4. Download result when COMPLETED
+
+```http
+GET /api/saas/migration/jobs/abc-123/download
+```
+
+### 5. Import a file
+
+```http
+POST /api/saas/migration/jobs/import
+Content-Type: multipart/form-data
+
+file=@tenant_backup.json.gz
+targetAccountId=99
+identityStrategy=REGENERATE_IDS
+```
+
+### 6. Clone a tenant
+
+```http
+POST /api/saas/migration/jobs/clone
+Content-Type: application/json
+
+{
+ "sourceAccountId": 42,
+ "targetAccountId": 99,
+ "identityStrategy": "REGENERATE_IDS"
+}
+```
+
+---
+
+## REST API Reference
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `POST` | `/api/saas/migration/jobs/export/{accountId}` | Start export job |
+| `POST` | `/api/saas/migration/jobs/import` | Start import job (multipart) |
+| `POST` | `/api/saas/migration/jobs/clone` | Start clone job |
+| `POST` | `/api/saas/migration/jobs/backup/{accountId}` | Start backup job |
+| `POST` | `/api/saas/migration/jobs/restore/{accountId}` | Start restore job (multipart) |
+| `GET` | `/api/saas/migration/jobs` | List all jobs |
+| `GET` | `/api/saas/migration/jobs/{jobId}` | Get job status & progress |
+| `POST` | `/api/saas/migration/jobs/{jobId}/cancel` | Cancel a running job |
+| `GET` | `/api/saas/migration/jobs/{jobId}/download` | Download result file |
+
+---
+
+## Configuration
+
+```properties
+# saas-migration
+dynamia.saas.migration.chunk-size=500
+dynamia.saas.migration.output-directory=${java.io.tmpdir}/saas-migration
+dynamia.saas.migration.compression-enabled=false
+dynamia.saas.migration.max-concurrent-jobs=5
+dynamia.saas.migration.fail-on-entity-error=false
+```
+
+---
+
+## Identity Strategies
+
+| Strategy | Description |
+|----------|-------------|
+| `KEEP_IDS` | Preserve original database IDs. Suitable for cross-environment restore to an empty target DB. |
+| `REGENERATE_IDS` | Assign new auto-generated IDs. Safe for cloning within the same DB. |
+| `UUID7` | (Planned v3) Use UUIDv7 for all IDs. |
+
+---
+
+## Export Format
+
+```json
+{
+ "version": "1",
+ "exportedAt": "2026-06-14T10:05:00",
+ "sourceAccountId": 42,
+ "identityStrategy": "KEEP_IDS",
+ "account": { "id": 42, "name": "Acme Corp", ... },
+ "entities": {
+ "com.example.Customer": [
+ { "id": 1, "accountId": 42, "name": "John", "type_ref_id": 5 },
+ ...
+ ],
+ "com.example.Order": [
+ { "id": 10, "accountId": 42, "customer_ref_id": 1, ... }
+ ]
+ }
+}
+```
+
+Relationship fields (`@ManyToOne`, `@OneToOne`) are serialized as `{fieldName}_ref_id` to decouple the export from JPA serialization complexity. Collections (`@OneToMany`, `@ManyToMany`) are reconstructed naturally when child entities reference their parents.
+
+---
+
+## Extension Points (SPI)
+
+| Interface | Description |
+|-----------|-------------|
+| `IdentityMapper` | Custom ID mapping strategy |
+| `MigrationProgressListener` | Hook for progress events |
+| `CancellationToken` | Cooperative cancellation signal |
+
+---
+
+## Architecture
+
+See [ARCHITECTURE.md](ARCHITECTURE.md) for detailed design decisions, component diagrams, and implementation roadmap.
+
+---
+
+## License
+
+Apache License 2.0 — Copyright © 2026 Dynamia Soluciones IT S.A.S
+
diff --git a/extensions/saas/sources/migration/pom.xml b/extensions/saas/sources/migration/pom.xml
new file mode 100644
index 00000000..58eb7627
--- /dev/null
+++ b/extensions/saas/sources/migration/pom.xml
@@ -0,0 +1,139 @@
+
+
+
+
+ 4.0.0
+
+
+ tools.dynamia.modules
+ tools.dynamia.modules.saas.parent
+ 26.6.0
+
+
+ DynamiaModules - SaaS Tenant Mobility (Migration)
+ tools.dynamia.modules.saas.migration
+ https://www.dynamia.tools/modules/saas/migration
+
+ Tenant Mobility module for DynamiaTools SaaS. Provides async export, import, clone,
+ backup and restore of tenant data via streaming JSON with JPA-based entity discovery.
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ ${maven.compiler}
+
+ ${java.version}
+ ${java.version}
+ ${source.encoding}
+ true
+
+
+
+
+
+
+
+
+
+ tools.dynamia.modules
+ tools.dynamia.modules.saas
+ 26.6.0
+
+
+
+
+ tools.dynamia.modules
+ tools.dynamia.modules.saas.api
+ 26.6.0
+
+
+
+
+ tools.dynamia.modules
+ tools.dynamia.modules.saas.jpa
+ 26.6.0
+
+
+
+
+ tools.dynamia
+ tools.dynamia.integration
+ 26.6.0
+
+
+
+
+ tools.dynamia
+ tools.dynamia.domain.jpa
+ 26.6.0
+
+
+
+
+ org.hibernate.orm
+ hibernate-core
+
+
+
+
+ org.springframework.boot
+ spring-boot-autoconfigure
+
+
+
+
+ tools.jackson.core
+ jackson-databind
+
+
+
+
+ jakarta.servlet
+ jakarta.servlet-api
+ provided
+
+
+ jakarta.annotation
+ jakarta.annotation-api
+ provided
+
+
+
+
+ junit
+ junit
+ ${junit.version}
+ test
+
+
+
+ org.mockito
+ mockito-core
+ 5.20.0
+ test
+
+
+
+
+
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountCloneOptions.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountCloneOptions.java
new file mode 100644
index 00000000..a057a28b
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountCloneOptions.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.api;
+
+/**
+ * Options for a clone operation (source tenant → target tenant, same system).
+ *
+ * @author Mario Serrano Leones
+ */
+public class AccountCloneOptions {
+
+ /** ID of the account to clone data from. Required. */
+ private Long sourceAccountId;
+
+ /** ID of the (already existing) target account. Required. */
+ private Long targetAccountId;
+
+ /**
+ * Strategy for handling IDs.
+ * Defaults to {@link IdentityStrategy#REGENERATE_IDS} because clone typically
+ * happens within the same database.
+ */
+ private IdentityStrategy identityStrategy = IdentityStrategy.REGENERATE_IDS;
+
+ /** Records per page during export/import. Default: 500. */
+ private int chunkSize = 500;
+
+ /**
+ * When {@code true}, entity errors are fatal. When {@code false}, they are
+ * logged and the clone continues. Default: {@code false}.
+ */
+ private boolean failOnEntityError = false;
+
+ // ─── Fluent builder ────────────────────────────────────────────────────────
+
+ public AccountCloneOptions source(Long sourceAccountId) {
+ this.sourceAccountId = sourceAccountId;
+ return this;
+ }
+
+ public AccountCloneOptions target(Long targetAccountId) {
+ this.targetAccountId = targetAccountId;
+ return this;
+ }
+
+ public AccountCloneOptions identityStrategy(IdentityStrategy identityStrategy) {
+ this.identityStrategy = identityStrategy;
+ return this;
+ }
+
+ public AccountCloneOptions chunkSize(int chunkSize) {
+ this.chunkSize = chunkSize;
+ return this;
+ }
+
+ // ─── Accessors ─────────────────────────────────────────────────────────────
+
+ public Long getSourceAccountId() {
+ return sourceAccountId;
+ }
+
+ public void setSourceAccountId(Long sourceAccountId) {
+ this.sourceAccountId = sourceAccountId;
+ }
+
+ public Long getTargetAccountId() {
+ return targetAccountId;
+ }
+
+ public void setTargetAccountId(Long targetAccountId) {
+ this.targetAccountId = targetAccountId;
+ }
+
+ public IdentityStrategy getIdentityStrategy() {
+ return identityStrategy;
+ }
+
+ public void setIdentityStrategy(IdentityStrategy identityStrategy) {
+ this.identityStrategy = identityStrategy;
+ }
+
+ public int getChunkSize() {
+ return chunkSize;
+ }
+
+ public void setChunkSize(int chunkSize) {
+ this.chunkSize = chunkSize;
+ }
+
+ public boolean isFailOnEntityError() {
+ return failOnEntityError;
+ }
+
+ public void setFailOnEntityError(boolean failOnEntityError) {
+ this.failOnEntityError = failOnEntityError;
+ }
+}
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountExportOptions.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountExportOptions.java
new file mode 100644
index 00000000..105437f0
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountExportOptions.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.api;
+
+/**
+ * Options controlling a tenant export operation.
+ *
+ * @author Mario Serrano Leones
+ */
+public class AccountExportOptions {
+
+ /** Number of records to read from DB per pagination page. Default: 500. */
+ private int chunkSize = 500;
+
+ /** When {@code true}, the output stream is wrapped in GZIP compression. */
+ private boolean compressionEnabled = false;
+
+ /** Controls how IDs are represented in the exported file. */
+ private IdentityStrategy identityStrategy = IdentityStrategy.KEEP_IDS;
+
+ /** Optional display name for this export (used in file names and job labels). */
+ private String label;
+
+ // ─── Constructors ──────────────────────────────────────────────────────────
+
+ public AccountExportOptions() {
+ }
+
+ public AccountExportOptions(IdentityStrategy identityStrategy) {
+ this.identityStrategy = identityStrategy;
+ }
+
+ // ─── Fluent builder ────────────────────────────────────────────────────────
+
+ public AccountExportOptions chunkSize(int chunkSize) {
+ this.chunkSize = chunkSize;
+ return this;
+ }
+
+ public AccountExportOptions compressionEnabled(boolean compressionEnabled) {
+ this.compressionEnabled = compressionEnabled;
+ return this;
+ }
+
+ public AccountExportOptions identityStrategy(IdentityStrategy identityStrategy) {
+ this.identityStrategy = identityStrategy;
+ return this;
+ }
+
+ public AccountExportOptions label(String label) {
+ this.label = label;
+ return this;
+ }
+
+ // ─── Accessors ─────────────────────────────────────────────────────────────
+
+ public int getChunkSize() {
+ return chunkSize;
+ }
+
+ public void setChunkSize(int chunkSize) {
+ this.chunkSize = chunkSize;
+ }
+
+ public boolean isCompressionEnabled() {
+ return compressionEnabled;
+ }
+
+ public void setCompressionEnabled(boolean compressionEnabled) {
+ this.compressionEnabled = compressionEnabled;
+ }
+
+ public IdentityStrategy getIdentityStrategy() {
+ return identityStrategy;
+ }
+
+ public void setIdentityStrategy(IdentityStrategy identityStrategy) {
+ this.identityStrategy = identityStrategy;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+
+ public void setLabel(String label) {
+ this.label = label;
+ }
+}
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountImportOptions.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountImportOptions.java
new file mode 100644
index 00000000..3a8a1a53
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountImportOptions.java
@@ -0,0 +1,95 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.api;
+
+/**
+ * Options controlling a tenant import operation.
+ *
+ * @author Mario Serrano Leones
+ */
+public class AccountImportOptions {
+
+ /**
+ * Target account ID.
+ * When {@code null}, the import will attempt to create a new account from
+ * the {@code account} section of the export file.
+ */
+ private Long targetAccountId;
+
+ /** How to handle primary keys when persisting imported entities. */
+ private IdentityStrategy identityStrategy = IdentityStrategy.REGENERATE_IDS;
+
+ /** Number of entities to persist per transaction batch. Default: 500. */
+ private int chunkSize = 500;
+
+ /**
+ * When {@code true}, the import fails immediately if any entity cannot be
+ * persisted. When {@code false}, errors are logged and the import continues.
+ */
+ private boolean failOnEntityError = false;
+
+ // ─── Fluent builder ────────────────────────────────────────────────────────
+
+ public AccountImportOptions targetAccountId(Long targetAccountId) {
+ this.targetAccountId = targetAccountId;
+ return this;
+ }
+
+ public AccountImportOptions identityStrategy(IdentityStrategy identityStrategy) {
+ this.identityStrategy = identityStrategy;
+ return this;
+ }
+
+ public AccountImportOptions chunkSize(int chunkSize) {
+ this.chunkSize = chunkSize;
+ return this;
+ }
+
+ public AccountImportOptions failOnEntityError(boolean failOnEntityError) {
+ this.failOnEntityError = failOnEntityError;
+ return this;
+ }
+
+ // ─── Accessors ─────────────────────────────────────────────────────────────
+
+ public Long getTargetAccountId() {
+ return targetAccountId;
+ }
+
+ public void setTargetAccountId(Long targetAccountId) {
+ this.targetAccountId = targetAccountId;
+ }
+
+ public IdentityStrategy getIdentityStrategy() {
+ return identityStrategy;
+ }
+
+ public void setIdentityStrategy(IdentityStrategy identityStrategy) {
+ this.identityStrategy = identityStrategy;
+ }
+
+ public int getChunkSize() {
+ return chunkSize;
+ }
+
+ public void setChunkSize(int chunkSize) {
+ this.chunkSize = chunkSize;
+ }
+
+ public boolean isFailOnEntityError() {
+ return failOnEntityError;
+ }
+
+ public void setFailOnEntityError(boolean failOnEntityError) {
+ this.failOnEntityError = failOnEntityError;
+ }
+}
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationJobDto.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationJobDto.java
new file mode 100644
index 00000000..0a9a2980
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationJobDto.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.api;
+
+import tools.dynamia.modules.saas.migration.domain.AccountJobStatus;
+import tools.dynamia.modules.saas.migration.domain.AccountJobType;
+import tools.dynamia.modules.saas.migration.domain.AccountMigrationJob;
+
+import java.time.LocalDateTime;
+
+/**
+ * Read-only DTO representing the state of a {@link AccountMigrationJob}.
+ * Returned by REST endpoints.
+ *
+ * @author Mario Serrano Leones
+ */
+public record AccountMigrationJobDto(
+ Long id,
+ String uuid,
+ Long accountId,
+ Long targetAccountId,
+ AccountJobType jobType,
+ AccountJobStatus status,
+ int progress,
+ String progressMessage,
+ String errorMessage,
+ String downloadUrl,
+ LocalDateTime createdAt,
+ LocalDateTime startedAt,
+ LocalDateTime finishedAt
+) {
+
+ /** Convenience: returns {@code true} when the job has reached a terminal state. */
+ public boolean isFinished() {
+ return status == AccountJobStatus.COMPLETED
+ || status == AccountJobStatus.FAILED
+ || status == AccountJobStatus.CANCELLED;
+ }
+}
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationJobService.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationJobService.java
new file mode 100644
index 00000000..f75c9bee
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationJobService.java
@@ -0,0 +1,113 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.api;
+
+import org.springframework.web.multipart.MultipartFile;
+import tools.dynamia.modules.saas.migration.domain.AccountMigrationJob;
+
+import java.util.List;
+
+/**
+ * Async job management service for tenant mobility operations.
+ *
+ * Each method creates a {@link AccountMigrationJob} record, launches the operation
+ * as a background virtual thread via {@code SchedulerUtil.runWithResult()}, and returns
+ * the job DTO immediately (non-blocking).
+ *
+ *
{@code
+ * TenantMobilityJobDto job = jobService.createExportJob(42L, new TenantExportOptions());
+ * // poll status:
+ * TenantMobilityJobDto status = jobService.getJob(job.uuid());
+ * }
+ *
+ * @author Mario Serrano Leones
+ */
+public interface AccountMigrationJobService {
+
+ /**
+ * Starts an async export job for the given account.
+ *
+ * @param accountId ID of the account to export
+ * @param options export configuration
+ * @return the newly created (PENDING) job
+ */
+ AccountMigrationJobDto createExportJob(Long accountId, AccountExportOptions options);
+
+ /**
+ * Starts an async import job from an uploaded file.
+ *
+ * @param file multipart upload containing the export JSON (or .json.gz)
+ * @param options import configuration (target account, identity strategy, etc.)
+ * @return the newly created (PENDING) job
+ */
+ AccountMigrationJobDto createImportJob(MultipartFile file, AccountImportOptions options);
+
+ /**
+ * Starts an async clone job (source tenant → target tenant, same system).
+ *
+ * @param options clone configuration
+ * @return the newly created (PENDING) job
+ */
+ AccountMigrationJobDto createCloneJob(AccountCloneOptions options);
+
+ /**
+ * Starts an async backup job (semantically equivalent to export with BACKUP type label).
+ *
+ * @param accountId ID of the account to back up
+ * @return the newly created (PENDING) job
+ */
+ AccountMigrationJobDto createBackupJob(Long accountId);
+
+ /**
+ * Starts an async restore job from an uploaded file
+ * (semantically equivalent to import with RESTORE type label).
+ *
+ * @param accountId target account to restore into
+ * @param file multipart upload
+ * @return the newly created (PENDING) job
+ */
+ AccountMigrationJobDto createRestoreJob(Long accountId, MultipartFile file);
+
+ /**
+ * Returns the current state of the job identified by {@code jobUuid}.
+ *
+ * @param jobUuid UUID of the job
+ * @return job DTO or {@code null} if not found
+ */
+ AccountMigrationJobDto getJob(String jobUuid);
+
+ /**
+ * Returns the raw {@link AccountMigrationJob} entity for internal use (e.g. file download).
+ *
+ * @param jobUuid UUID of the job
+ * @return entity or {@code null}
+ */
+ AccountMigrationJob getJobEntity(String jobUuid);
+
+ /**
+ * Lists all known jobs, optionally filtered by account.
+ *
+ * @param accountId filter by account; pass {@code null} to return all jobs
+ * @return list of jobs ordered by creation date descending
+ */
+ List listJobs(Long accountId);
+
+ /**
+ * Requests cooperative cancellation of a running job.
+ *
+ * The pipeline will stop at the next chunk boundary. If the job is already
+ * finished (COMPLETED/FAILED/CANCELLED), this is a no-op.
+ *
+ * @param jobUuid UUID of the job to cancel
+ */
+ void cancelJob(String jobUuid);
+}
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationService.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationService.java
new file mode 100644
index 00000000..8384e1e9
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationService.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.api;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * High-level service for executing tenant mobility operations synchronously.
+ *
+ *
This service is designed to be called from within a worker/job that is already
+ * running in a background virtual thread. For async job management see
+ * {@link AccountMigrationJobService}.
+ *
+ *
{@code
+ * // Direct usage (synchronous — blocks until complete):
+ * mobilityService.exportTenant(42L, outputStream, new TenantExportOptions(), listener, token);
+ *
+ * // Preferred usage (async via job service):
+ * jobService.createExportJob(42L, new TenantExportOptions());
+ * }
+ *
+ * @author Mario Serrano Leones
+ */
+public interface AccountMigrationService {
+
+ /**
+ * Exports all tenant data for {@code accountId} to the given {@code output} stream.
+ *
+ * @param accountId account whose data will be exported
+ * @param output destination stream (may be wrapped in GZIP by the pipeline if configured)
+ * @param options export configuration
+ * @param listener optional progress callback; may be {@code null}
+ * @param token optional cancellation token; may be {@code null}
+ */
+ void exportTenant(Long accountId,
+ OutputStream output,
+ AccountExportOptions options,
+ MigrationProgressListener listener,
+ CancellationToken token);
+
+ /**
+ * Imports tenant data from an exported {@code input} stream.
+ *
+ * @param input source stream (GZIP-encoded or plain JSON)
+ * @param options import configuration, including target account and identity strategy
+ * @param listener optional progress callback; may be {@code null}
+ * @param token optional cancellation token; may be {@code null}
+ */
+ void importTenant(InputStream input,
+ AccountImportOptions options,
+ MigrationProgressListener listener,
+ CancellationToken token);
+
+ /**
+ * Clones a tenant within the same system by exporting to an in-memory buffer
+ * and immediately importing to the target account.
+ *
+ * @param options clone configuration (source/target accounts, identity strategy, etc.)
+ * @param listener optional progress callback; may be {@code null}
+ * @param token optional cancellation token; may be {@code null}
+ */
+ void cloneTenant(AccountCloneOptions options,
+ MigrationProgressListener listener,
+ CancellationToken token);
+}
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/CancellationToken.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/CancellationToken.java
new file mode 100644
index 00000000..9abfb04b
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/CancellationToken.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.api;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Cooperative cancellation signal for long-running migration pipelines.
+ *
+ * The pipeline checks {@link #isCancelled()} between processing chunks.
+ * When cancellation is requested, the pipeline exits cleanly at the next checkpoint.
+ *
+ *
{@code
+ * CancellationToken token = new CancellationToken();
+ * // Pass to pipeline; later, from another thread:
+ * token.cancel();
+ * }
+ *
+ * @author Mario Serrano Leones
+ */
+public class CancellationToken {
+
+ private final AtomicBoolean cancelled = new AtomicBoolean(false);
+ private volatile String reason;
+
+ /** Signals the pipeline to stop at the next checkpoint. */
+ public void cancel() {
+ cancel("Cancelled by user");
+ }
+
+ /**
+ * Signals cancellation with a reason message.
+ *
+ * @param reason human-readable reason for cancellation
+ */
+ public void cancel(String reason) {
+ this.reason = reason;
+ this.cancelled.set(true);
+ }
+
+ /**
+ * Returns {@code true} if cancellation has been requested.
+ * Pipelines should check this between chunks and exit gracefully.
+ */
+ public boolean isCancelled() {
+ return cancelled.get();
+ }
+
+ /** Returns the cancellation reason, or {@code null} if not cancelled. */
+ public String getReason() {
+ return reason;
+ }
+
+ /** Factory method for convenience. */
+ public static CancellationToken active() {
+ return new CancellationToken();
+ }
+}
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/IdentityMapper.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/IdentityMapper.java
new file mode 100644
index 00000000..e0a4916c
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/IdentityMapper.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.api;
+
+import java.util.Map;
+
+/**
+ * SPI for controlling how entity primary keys are handled during import.
+ *
+ * The default implementation is {@link tools.dynamia.modules.saas.migration.identity.RegenerateIdsIdentityMapper}
+ * which assigns new JPA-generated IDs and resolves internal references via an
+ * {@code idMappings} table ({@code entityClass.name → {originalId → newId}}).
+ *
+ *
Implement and register this interface as a Spring bean to override the default behaviour.
+ *
+ * @author Mario Serrano Leones
+ */
+public interface IdentityMapper {
+
+ /**
+ * Determines the ID to use when persisting an imported entity.
+ *
+ * @param originalId the ID read from the export file; may be null
+ * @param entityClass the JPA entity class being imported
+ * @return the ID to assign before persisting, or {@code null} to let JPA auto-generate
+ */
+ Object mapId(Object originalId, Class> entityClass);
+
+ /**
+ * Resolves a foreign-key reference ID from the export file to the correct ID
+ * in the target database, using the running ID mapping table.
+ *
+ * @param originalRefId the reference ID read from the export ({@code fieldName_ref_id})
+ * @param refClass the referenced entity class
+ * @param idMappings mutable map of {@code className → {originalId → newId}}; updated during import
+ * @return the actual ID to use when creating the JPA reference proxy
+ */
+ Object resolveReferenceId(Object originalRefId, Class> refClass,
+ Map> idMappings);
+
+ /** Returns the strategy implemented by this mapper. */
+ IdentityStrategy getStrategy();
+}
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/IdentityStrategy.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/IdentityStrategy.java
new file mode 100644
index 00000000..3f8f58c7
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/IdentityStrategy.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.api;
+
+/**
+ * Controls how entity primary keys are handled during import.
+ *
+ * @author Mario Serrano Leones
+ */
+public enum IdentityStrategy {
+
+ /**
+ * Preserve original database IDs.
+ *
+ * The imported entities will have the same primary keys as the source system.
+ * This is safe when restoring to an empty target database.
+ * It may cause constraint violations if the target DB already contains data.
+ */
+ KEEP_IDS,
+
+ /**
+ * Auto-generate new IDs for all imported entities.
+ *
+ * JPA auto-generation is used for each entity. Foreign-key references are
+ * resolved via the internal ID mapping table ({@code originalId → newId}).
+ * This is the recommended strategy for cloning within the same database.
+ */
+ REGENERATE_IDS,
+
+ /**
+ * Assign UUIDv7 values as new IDs.
+ *
+ * Planned for v3.
+ */
+ UUID7
+}
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/MigrationException.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/MigrationException.java
new file mode 100644
index 00000000..ab26c2de
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/MigrationException.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.api;
+
+/**
+ * Unchecked exception thrown when a migration pipeline encounters an unrecoverable error.
+ *
+ * @author Mario Serrano Leones
+ */
+public class MigrationException extends RuntimeException {
+
+ public MigrationException(String message) {
+ super(message);
+ }
+
+ public MigrationException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public MigrationException(Throwable cause) {
+ super(cause);
+ }
+}
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/MigrationProgress.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/MigrationProgress.java
new file mode 100644
index 00000000..05a05ce3
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/MigrationProgress.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.api;
+
+/**
+ * Carries progress information during a tenant migration operation.
+ *
+ * @param processedRecords Total records processed so far.
+ * @param totalRecords Total records expected (0 if unknown).
+ * @param message Human-readable description of the current step.
+ * @author Mario Serrano Leones
+ */
+public record MigrationProgress(long processedRecords, long totalRecords, String message) {
+
+ /** Returns the progress as a percentage (0–100), or -1 if total is unknown. */
+ public int percentage() {
+ if (totalRecords <= 0) return -1;
+ return (int) Math.min(100, (processedRecords * 100L) / totalRecords);
+ }
+
+ @Override
+ public String toString() {
+ if (totalRecords > 0) {
+ return "[%d%%] %s (%d / %d)".formatted(percentage(), message, processedRecords, totalRecords);
+ }
+ return "[?] %s (%d processed)".formatted(message, processedRecords);
+ }
+}
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/MigrationProgressListener.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/MigrationProgressListener.java
new file mode 100644
index 00000000..a8b090ee
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/MigrationProgressListener.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.api;
+
+/**
+ * SPI callback for receiving progress updates during a migration pipeline execution.
+ *
+ *
Implementations are typically provided by the job service to persist progress
+ * in the {@code TenantMobilityJob} entity.
+ *
+ * @author Mario Serrano Leones
+ */
+@FunctionalInterface
+public interface MigrationProgressListener {
+
+ /**
+ * Called by the pipeline whenever significant progress has been made.
+ *
+ * @param progress current progress snapshot
+ */
+ void onProgress(MigrationProgress progress);
+}
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/AccountMigrationConfig.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/AccountMigrationConfig.java
new file mode 100644
index 00000000..ac6c39a5
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/AccountMigrationConfig.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.config;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import tools.jackson.databind.DeserializationFeature;
+import tools.jackson.databind.ObjectMapper;
+import tools.jackson.databind.SerializationFeature;
+import tools.jackson.databind.json.JsonMapper;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/**
+ * Spring Boot auto-configuration for the Tenant Mobility module.
+ *
+ *
Registers:
+ *
+ * A migration-specific {@link ObjectMapper} (qualified name: {@code migrationObjectMapper}).
+ * The default {@link tools.dynamia.modules.saas.migration.api.AccountMigrationService} implementation.
+ * Ensures the output directory exists at startup.
+ *
+ *
+ * @author Mario Serrano Leones
+ */
+@Configuration
+@EnableConfigurationProperties(AccountMigrationProperties.class)
+public class AccountMigrationConfig {
+
+ private final AccountMigrationProperties properties;
+
+ public AccountMigrationConfig(AccountMigrationProperties properties) {
+ this.properties = properties;
+ initOutputDirectory();
+ }
+
+ /**
+ * Dedicated Jackson {@link ObjectMapper} for the migration pipelines.
+ *
+ * Configured to:
+ *
+ * Java time types supported natively (Jackson 3 built-in, no module needed).
+ * Not fail on unknown properties during import.
+ * Not fail on empty beans.
+ * Exclude null values from output (smaller files).
+ *
+ *
+ * This bean is named {@code migrationObjectMapper} so it does not conflict
+ * with any other {@code ObjectMapper} in the application context.
+ */
+ @Bean("migrationObjectMapper")
+ @ConditionalOnMissingBean(name = "migrationObjectMapper")
+ public ObjectMapper migrationObjectMapper() {
+ return JsonMapper.builder()
+ .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
+ .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
+ .changeDefaultPropertyInclusion(
+ i -> i.withValueInclusion(JsonInclude.Include.NON_NULL))
+ .build();
+ }
+
+ private void initOutputDirectory() {
+ try {
+ Path dir = Path.of(properties.getOutputDirectory());
+ if (!Files.exists(dir)) {
+ Files.createDirectories(dir);
+ }
+ } catch (IOException e) {
+ throw new RuntimeException(
+ "Cannot create migration output directory: " + properties.getOutputDirectory(), e);
+ }
+ }
+}
+
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/AccountMigrationProperties.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/AccountMigrationProperties.java
new file mode 100644
index 00000000..43334185
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/AccountMigrationProperties.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * Configuration properties for the Tenant Mobility module.
+ *
+ *
All properties are prefixed with {@code dynamia.saas.migration}.
+ *
+ *
Example {@code application.properties}:
+ *
+ * dynamia.saas.migration.chunk-size=500
+ * dynamia.saas.migration.output-directory=/var/data/saas-migration
+ * dynamia.saas.migration.compression-enabled=false
+ * dynamia.saas.migration.max-concurrent-jobs=5
+ * dynamia.saas.migration.fail-on-entity-error=false
+ *
+ *
+ * @author Mario Serrano Leones
+ */
+@ConfigurationProperties(prefix = "dynamia.saas.migration")
+public class AccountMigrationProperties {
+
+ /** Number of records read/written per pagination page. Default: 500. */
+ private int chunkSize = 500;
+
+ /**
+ * Directory where export/backup files are stored.
+ * Defaults to {@code ${java.io.tmpdir}/saas-migration}.
+ */
+ private String outputDirectory = System.getProperty("java.io.tmpdir") + "/saas-migration";
+
+ /** Whether to compress output files with GZIP by default. Default: {@code false}. */
+ private boolean compressionEnabled = false;
+
+ /**
+ * Maximum number of jobs that can be in RUNNING state simultaneously.
+ * Additional jobs remain in PENDING and are started as running jobs finish.
+ * Default: 5.
+ */
+ private int maxConcurrentJobs = 5;
+
+ /**
+ * If {@code true}, the import pipeline stops immediately when any entity
+ * fails to persist. If {@code false}, errors are logged and the import
+ * continues. Default: {@code false}.
+ */
+ private boolean failOnEntityError = false;
+
+ // ─── Accessors ─────────────────────────────────────────────────────────────
+
+ public int getChunkSize() {
+ return chunkSize;
+ }
+
+ public void setChunkSize(int chunkSize) {
+ this.chunkSize = chunkSize;
+ }
+
+ public String getOutputDirectory() {
+ return outputDirectory;
+ }
+
+ public void setOutputDirectory(String outputDirectory) {
+ this.outputDirectory = outputDirectory;
+ }
+
+ public boolean isCompressionEnabled() {
+ return compressionEnabled;
+ }
+
+ public void setCompressionEnabled(boolean compressionEnabled) {
+ this.compressionEnabled = compressionEnabled;
+ }
+
+ public int getMaxConcurrentJobs() {
+ return maxConcurrentJobs;
+ }
+
+ public void setMaxConcurrentJobs(int maxConcurrentJobs) {
+ this.maxConcurrentJobs = maxConcurrentJobs;
+ }
+
+ public boolean isFailOnEntityError() {
+ return failOnEntityError;
+ }
+
+ public void setFailOnEntityError(boolean failOnEntityError) {
+ this.failOnEntityError = failOnEntityError;
+ }
+}
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/controllers/AccountMigrationRestController.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/controllers/AccountMigrationRestController.java
new file mode 100644
index 00000000..5c957164
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/controllers/AccountMigrationRestController.java
@@ -0,0 +1,250 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.controllers;
+
+import org.springframework.core.io.FileSystemResource;
+import org.springframework.core.io.Resource;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+import tools.dynamia.modules.saas.migration.api.AccountCloneOptions;
+import tools.dynamia.modules.saas.migration.api.AccountExportOptions;
+import tools.dynamia.modules.saas.migration.api.AccountImportOptions;
+import tools.dynamia.modules.saas.migration.api.AccountMigrationJobDto;
+import tools.dynamia.modules.saas.migration.api.AccountMigrationJobService;
+import tools.dynamia.modules.saas.migration.domain.AccountMigrationJob;
+
+import java.io.File;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * REST API for the Tenant Mobility module.
+ *
+ * Endpoints
+ *
+ * POST /api/saas/migration/jobs/export/{accountId} → start export job
+ * POST /api/saas/migration/jobs/import → start import job (multipart)
+ * POST /api/saas/migration/jobs/clone → start clone job
+ * POST /api/saas/migration/jobs/backup/{accountId} → start backup job
+ * POST /api/saas/migration/jobs/restore/{accountId} → start restore job (multipart)
+ * GET /api/saas/migration/jobs → list all jobs
+ * GET /api/saas/migration/jobs/{jobId} → get job status
+ * POST /api/saas/migration/jobs/{jobId}/cancel → cancel a running job
+ * GET /api/saas/migration/jobs/{jobId}/download → download result file
+ *
+ *
+ * Note: Authorization is NOT enforced by this controller — the host
+ * application is responsible for securing these endpoints (e.g., via Spring Security,
+ * admin role checks, or API key filtering). These endpoints deal with raw tenant data
+ * and should be restricted to system administrators only.
+ *
+ * @author Mario Serrano Leones
+ */
+@RestController
+@RequestMapping(value = "/api/saas/migration", produces = MediaType.APPLICATION_JSON_VALUE)
+public class AccountMigrationRestController {
+
+ private final AccountMigrationJobService jobService;
+
+ public AccountMigrationRestController(AccountMigrationJobService jobService) {
+ this.jobService = jobService;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Export
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Start an export job for the specified account.
+ *
+ *
Request body (optional JSON):
+ *
{@code
+ * {
+ * "chunkSize": 500,
+ * "compressionEnabled": true,
+ * "identityStrategy": "KEEP_IDS"
+ * }
+ * }
+ */
+ @PostMapping("/jobs/export/{accountId}")
+ public ResponseEntity startExport(
+ @PathVariable Long accountId,
+ @RequestBody(required = false) AccountExportOptions options) {
+
+ AccountExportOptions opts = options != null ? options : new AccountExportOptions();
+ AccountMigrationJobDto job = jobService.createExportJob(accountId, opts);
+ return ResponseEntity.status(HttpStatus.ACCEPTED).body(job);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Import
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Start an import job from an uploaded export file.
+ *
+ * Form fields:
+ *
+ * {@code file} — the export JSON or JSON.GZ file (required)
+ * {@code targetAccountId} — target account ID (optional; null = create from file)
+ * {@code identityStrategy} — KEEP_IDS or REGENERATE_IDS (optional)
+ * {@code chunkSize} — records per transaction (optional)
+ *
+ */
+ @PostMapping(value = "/jobs/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ public ResponseEntity startImport(
+ @RequestParam("file") MultipartFile file,
+ @RequestParam(required = false) Long targetAccountId,
+ @RequestParam(required = false) String identityStrategy,
+ @RequestParam(required = false, defaultValue = "0") int chunkSize) {
+
+ AccountImportOptions options = new AccountImportOptions()
+ .targetAccountId(targetAccountId)
+ .chunkSize(chunkSize > 0 ? chunkSize : 500);
+
+ if (identityStrategy != null) {
+ try {
+ options.setIdentityStrategy(
+ tools.dynamia.modules.saas.migration.api.IdentityStrategy.valueOf(identityStrategy));
+ } catch (IllegalArgumentException e) {
+ return ResponseEntity.badRequest().build();
+ }
+ }
+
+ AccountMigrationJobDto job = jobService.createImportJob(file, options);
+ return ResponseEntity.status(HttpStatus.ACCEPTED).body(job);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Clone
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Start a clone job (source tenant → target tenant, same system).
+ *
+ * Request body:
+ *
{@code
+ * {
+ * "sourceAccountId": 42,
+ * "targetAccountId": 99,
+ * "identityStrategy": "REGENERATE_IDS",
+ * "chunkSize": 500
+ * }
+ * }
+ */
+ @PostMapping("/jobs/clone")
+ public ResponseEntity startClone(
+ @RequestBody AccountCloneOptions options) {
+
+ if (options.getSourceAccountId() == null || options.getTargetAccountId() == null) {
+ return ResponseEntity.badRequest().build();
+ }
+ AccountMigrationJobDto job = jobService.createCloneJob(options);
+ return ResponseEntity.status(HttpStatus.ACCEPTED).body(job);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Backup / Restore
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /** Start a backup job (export with BACKUP type label and compression). */
+ @PostMapping("/jobs/backup/{accountId}")
+ public ResponseEntity startBackup(@PathVariable Long accountId) {
+ AccountMigrationJobDto job = jobService.createBackupJob(accountId);
+ return ResponseEntity.status(HttpStatus.ACCEPTED).body(job);
+ }
+
+ /** Start a restore job from an uploaded export file. */
+ @PostMapping(value = "/jobs/restore/{accountId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ public ResponseEntity startRestore(
+ @PathVariable Long accountId,
+ @RequestParam("file") MultipartFile file) {
+
+ AccountMigrationJobDto job = jobService.createRestoreJob(accountId, file);
+ return ResponseEntity.status(HttpStatus.ACCEPTED).body(job);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Job management
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /**
+ * List all jobs. Pass {@code ?accountId=X} to filter by account.
+ */
+ @GetMapping("/jobs")
+ public ResponseEntity> listJobs(
+ @RequestParam(required = false) Long accountId) {
+ return ResponseEntity.ok(jobService.listJobs(accountId));
+ }
+
+ /** Get the current status of a job by its UUID. */
+ @GetMapping("/jobs/{jobId}")
+ public ResponseEntity getJob(@PathVariable String jobId) {
+ AccountMigrationJobDto job = jobService.getJob(jobId);
+ if (job == null) return ResponseEntity.notFound().build();
+ return ResponseEntity.ok(job);
+ }
+
+ /** Request cancellation of a running job. Idempotent. */
+ @PostMapping("/jobs/{jobId}/cancel")
+ public ResponseEntity> cancelJob(@PathVariable String jobId) {
+ AccountMigrationJobDto job = jobService.getJob(jobId);
+ if (job == null) return ResponseEntity.notFound().build();
+ jobService.cancelJob(jobId);
+ return ResponseEntity.ok(Map.of("message", "Cancellation requested for job " + jobId));
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // File download
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Download the result file of a completed EXPORT or BACKUP job.
+ * Returns 404 if the job is not found, not completed, or has no result file.
+ */
+ @GetMapping("/jobs/{jobId}/download")
+ public ResponseEntity downloadResult(@PathVariable String jobId) {
+ AccountMigrationJob job = jobService.getJobEntity(jobId);
+ if (job == null || job.getResultPath() == null) {
+ return ResponseEntity.notFound().build();
+ }
+
+ File resultFile = Paths.get(job.getResultPath()).toFile();
+ if (!resultFile.exists()) {
+ return ResponseEntity.notFound().build();
+ }
+
+ String contentType = job.getResultPath().endsWith(".gz")
+ ? "application/gzip"
+ : "application/json";
+
+ String filename = resultFile.getName();
+
+ return ResponseEntity.ok()
+ .contentType(MediaType.parseMediaType(contentType))
+ .header(HttpHeaders.CONTENT_DISPOSITION,
+ "attachment; filename=\"" + filename + "\"")
+ .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(resultFile.length()))
+ .body(new FileSystemResource(resultFile));
+ }
+}
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/discovery/AccountEntityDiscovery.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/discovery/AccountEntityDiscovery.java
new file mode 100644
index 00000000..42a36af2
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/discovery/AccountEntityDiscovery.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.discovery;
+
+import jakarta.persistence.EntityManagerFactory;
+import jakarta.persistence.metamodel.EntityType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import tools.dynamia.integration.sterotypes.Service;
+import tools.dynamia.modules.saas.api.AccountAware;
+import tools.dynamia.modules.saas.api.AccountExportIgnore;
+import tools.dynamia.modules.saas.domain.Account;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Discovers all JPA entity classes that participate in the Tenant Mobility export/import.
+ *
+ * Discovery algorithm
+ *
+ * Retrieves all managed entity types from {@link EntityManagerFactory#getMetamodel()}.
+ * Filters to classes that implement {@link AccountAware}.
+ * Excludes classes annotated with {@link AccountExportIgnore}.
+ * Always includes {@link Account} (the tenant root), regardless of the above filters.
+ *
+ *
+ * @author Mario Serrano Leones
+ */
+@Service
+public class AccountEntityDiscovery {
+
+ private static final Logger log = LoggerFactory.getLogger(AccountEntityDiscovery.class);
+
+ private final EntityManagerFactory emf;
+
+ public AccountEntityDiscovery(EntityManagerFactory emf) {
+ this.emf = emf;
+ }
+
+ /**
+ * Returns the list of entity classes that should be included in the export.
+ * The list is not sorted; call
+ * {@link tools.dynamia.modules.saas.migration.graph.EntityDependencyGraph#topologicalSort(List)}
+ * to obtain the correct import order.
+ */
+ public List> discoverExportableEntities() {
+ Set> managedTypes = emf.getMetamodel().getEntities();
+ List> exportable = new ArrayList<>();
+
+ // Always include Account as the tenant root
+ exportable.add(Account.class);
+ log.debug("[Migration] Always including: {}", Account.class.getName());
+
+ for (EntityType> entityType : managedTypes) {
+ Class> javaType = entityType.getJavaType();
+
+ // Skip Account itself (already added above)
+ if (Account.class.equals(javaType)) {
+ continue;
+ }
+
+ // Skip entities not annotated as tenant-aware
+ if (!AccountAware.class.isAssignableFrom(javaType)) {
+ continue;
+ }
+
+ // Skip entities explicitly excluded from export
+ if (javaType.isAnnotationPresent(AccountExportIgnore.class)) {
+ log.debug("[Migration] Skipping @AccountExportIgnore entity: {}", javaType.getName());
+ continue;
+ }
+
+ exportable.add(javaType);
+ log.debug("[Migration] Discovered exportable entity: {}", javaType.getName());
+ }
+
+ log.info("[Migration] Discovered {} exportable entity types", exportable.size());
+ return exportable;
+ }
+}
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountJobStatus.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountJobStatus.java
new file mode 100644
index 00000000..6688409d
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountJobStatus.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.domain;
+
+/**
+ * Lifecycle status of a {@link AccountMigrationJob}.
+ *
+ * @author Mario Serrano Leones
+ */
+public enum AccountJobStatus {
+
+ /** Job has been created but not started yet. */
+ PENDING,
+
+ /** Job is currently executing in a background virtual thread. */
+ RUNNING,
+
+ /** Job finished successfully. Result file is available for download. */
+ COMPLETED,
+
+ /** Job failed with an error. See {@link AccountMigrationJob#getErrorMessage()}. */
+ FAILED,
+
+ /** Job was cancelled by the user before it completed. */
+ CANCELLED
+}
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountJobType.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountJobType.java
new file mode 100644
index 00000000..7cad29af
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountJobType.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.domain;
+
+/**
+ * Type of a {@link AccountMigrationJob}.
+ *
+ * @author Mario Serrano Leones
+ */
+public enum AccountJobType {
+
+ /** Export all tenant data to a JSON/GZIP file. */
+ EXPORT,
+
+ /** Import tenant data from a previously exported file. */
+ IMPORT,
+
+ /** Clone a tenant within the same system (source → target accountId). */
+ CLONE,
+
+ /** Export tagged as a backup (semantically identical to EXPORT). */
+ BACKUP,
+
+ /** Import that replaces existing tenant data (semantically identical to IMPORT). */
+ RESTORE,
+
+ /**
+ * Cross-environment migration: export locally + push to a remote endpoint.
+ * Planned for v2.
+ */
+ MIGRATE
+}
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountMigrationJob.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountMigrationJob.java
new file mode 100644
index 00000000..a4add282
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountMigrationJob.java
@@ -0,0 +1,244 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.domain;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.Table;
+import tools.dynamia.commons.StringUtils;
+import tools.dynamia.domain.jpa.SimpleEntity;
+import tools.dynamia.modules.saas.migration.api.AccountExportOptions;
+import tools.dynamia.modules.saas.migration.api.AccountImportOptions;
+
+import java.time.LocalDateTime;
+
+/**
+ * Persisted record of a tenant mobility operation (export, import, clone, backup, restore).
+ *
+ * Each row tracks the full lifecycle: PENDING → RUNNING → COMPLETED / FAILED / CANCELLED.
+ *
+ *
This entity is NOT {@code AccountAware} intentionally — it is a system-level record
+ * and must not be exported alongside tenant data.
+ *
+ * @author Mario Serrano Leones
+ */
+@Entity
+@Table(name = "saas_migration_jobs")
+public class AccountMigrationJob extends SimpleEntity {
+
+ // ─── Identity ──────────────────────────────────────────────────────────────
+
+ /** Stable external identifier (URL-safe, used in REST paths). */
+ @Column(nullable = false, unique = true, length = 64)
+ private String uuid = StringUtils.randomString();
+
+ // ─── Tenant references ─────────────────────────────────────────────────────
+
+ /** Source tenant account ID. */
+ private Long accountId;
+
+ /** Target tenant account ID (used for clone/restore operations). */
+ private Long targetAccountId;
+
+ // ─── Classification ────────────────────────────────────────────────────────
+
+ @Enumerated(EnumType.STRING)
+ @Column(length = 30)
+ private AccountJobType jobType;
+
+ @Enumerated(EnumType.STRING)
+ @Column(length = 30)
+ private AccountJobStatus status = AccountJobStatus.PENDING;
+
+ // ─── Progress ──────────────────────────────────────────────────────────────
+
+ /** Completion percentage 0–100. */
+ private int progress;
+
+ @Column(length = 2000)
+ private String progressMessage;
+
+ // ─── Timestamps ────────────────────────────────────────────────────────────
+
+ private LocalDateTime createdAt = LocalDateTime.now();
+ private LocalDateTime startedAt;
+ private LocalDateTime finishedAt;
+
+ // ─── Results & errors ──────────────────────────────────────────────────────
+
+ @Column(columnDefinition = "TEXT")
+ private String errorMessage;
+
+ /** Absolute path to the result file on disk (EXPORT / BACKUP jobs). */
+ @Column(length = 1000)
+ private String resultPath;
+
+ /** Serialized {@link AccountExportOptions}
+ * or {@link AccountImportOptions} as JSON. */
+ @Column(length = 4000)
+ private String optionsJson;
+
+ // ─── Helpers ───────────────────────────────────────────────────────────────
+
+ /** Mark the job as started. */
+ public void markRunning() {
+ this.status = AccountJobStatus.RUNNING;
+ this.startedAt = LocalDateTime.now();
+ }
+
+ /** Mark the job as successfully completed. */
+ public void markCompleted() {
+ this.status = AccountJobStatus.COMPLETED;
+ this.finishedAt = LocalDateTime.now();
+ this.progress = 100;
+ }
+
+ /** Mark the job as failed with an error message. */
+ public void markFailed(String errorMessage) {
+ this.status = AccountJobStatus.FAILED;
+ this.finishedAt = LocalDateTime.now();
+ this.errorMessage = errorMessage;
+ }
+
+ /** Mark the job as cancelled. */
+ public void markCancelled(String reason) {
+ this.status = AccountJobStatus.CANCELLED;
+ this.finishedAt = LocalDateTime.now();
+ this.progressMessage = reason;
+ }
+
+ /** Update running progress (0-100) and an optional human-readable message. */
+ public void updateProgress(int progress, String message) {
+ this.progress = Math.min(100, Math.max(0, progress));
+ this.progressMessage = message;
+ }
+
+ /** Returns {@code true} if the job is in a terminal state. */
+ public boolean isFinished() {
+ return status == AccountJobStatus.COMPLETED
+ || status == AccountJobStatus.FAILED
+ || status == AccountJobStatus.CANCELLED;
+ }
+
+ // ─── Accessors ─────────────────────────────────────────────────────────────
+
+ public String getUuid() {
+ return uuid;
+ }
+
+ public void setUuid(String uuid) {
+ this.uuid = uuid;
+ }
+
+ public Long getAccountId() {
+ return accountId;
+ }
+
+ public void setAccountId(Long accountId) {
+ this.accountId = accountId;
+ }
+
+ public Long getTargetAccountId() {
+ return targetAccountId;
+ }
+
+ public void setTargetAccountId(Long targetAccountId) {
+ this.targetAccountId = targetAccountId;
+ }
+
+ public AccountJobType getJobType() {
+ return jobType;
+ }
+
+ public void setJobType(AccountJobType jobType) {
+ this.jobType = jobType;
+ }
+
+ public AccountJobStatus getStatus() {
+ return status;
+ }
+
+ public void setStatus(AccountJobStatus status) {
+ this.status = status;
+ }
+
+ public int getProgress() {
+ return progress;
+ }
+
+ public void setProgress(int progress) {
+ this.progress = progress;
+ }
+
+ public String getProgressMessage() {
+ return progressMessage;
+ }
+
+ public void setProgressMessage(String progressMessage) {
+ this.progressMessage = progressMessage;
+ }
+
+ public LocalDateTime getCreatedAt() {
+ return createdAt;
+ }
+
+ public void setCreatedAt(LocalDateTime createdAt) {
+ this.createdAt = createdAt;
+ }
+
+ public LocalDateTime getStartedAt() {
+ return startedAt;
+ }
+
+ public void setStartedAt(LocalDateTime startedAt) {
+ this.startedAt = startedAt;
+ }
+
+ public LocalDateTime getFinishedAt() {
+ return finishedAt;
+ }
+
+ public void setFinishedAt(LocalDateTime finishedAt) {
+ this.finishedAt = finishedAt;
+ }
+
+ public String getErrorMessage() {
+ return errorMessage;
+ }
+
+ public void setErrorMessage(String errorMessage) {
+ this.errorMessage = errorMessage;
+ }
+
+ public String getResultPath() {
+ return resultPath;
+ }
+
+ public void setResultPath(String resultPath) {
+ this.resultPath = resultPath;
+ }
+
+ public String getOptionsJson() {
+ return optionsJson;
+ }
+
+ public void setOptionsJson(String optionsJson) {
+ this.optionsJson = optionsJson;
+ }
+
+ @Override
+ public String toString() {
+ return "TenantMobilityJob{uuid=" + uuid + ", type=" + jobType + ", status=" + status + "}";
+ }
+}
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/graph/EntityDependencyGraph.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/graph/EntityDependencyGraph.java
new file mode 100644
index 00000000..c149cda9
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/graph/EntityDependencyGraph.java
@@ -0,0 +1,144 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.graph;
+
+import jakarta.persistence.EntityManagerFactory;
+import jakarta.persistence.metamodel.Attribute.PersistentAttributeType;
+import jakarta.persistence.metamodel.EntityType;
+import jakarta.persistence.metamodel.SingularAttribute;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import tools.dynamia.integration.sterotypes.Service;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Queue;
+import java.util.Set;
+
+/**
+ * Builds a directed dependency graph of JPA entity classes and produces a
+ * topologically sorted import order (parents before children).
+ *
+ *
Graph construction
+ * For each entity {@code E} in the input set, every {@code @ManyToOne} /
+ * {@code @OneToOne} attribute that references another entity {@code P} in the
+ * same set creates a directed edge {@code P → E} (P must be imported before E).
+ *
+ * Topological sort
+ * Kahn's BFS algorithm is used. If a cycle is detected (unlikely with
+ * well-formed JPA models), the remaining nodes are appended in their original
+ * discovery order so the pipeline can still proceed.
+ *
+ * @author Mario Serrano Leones
+ */
+@Service
+public class EntityDependencyGraph {
+
+ private static final Logger log = LoggerFactory.getLogger(EntityDependencyGraph.class);
+
+ private final EntityManagerFactory emf;
+
+ public EntityDependencyGraph(EntityManagerFactory emf) {
+ this.emf = emf;
+ }
+
+ /**
+ * Returns the input list sorted so that every entity appears after all
+ * entities it references (i.e., safe insert order).
+ *
+ * @param entityClasses the set of entity classes to sort
+ * @return a new list in topological order
+ */
+ public List> topologicalSort(List> entityClasses) {
+ if (entityClasses == null || entityClasses.isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ Set> classSet = new HashSet<>(entityClasses);
+
+ // adjacency: node → set of nodes that depend on it (predecessors of edges)
+ Map, Set>> dependents = new HashMap<>();
+ // in-degree: how many entities this entity depends on (within our set)
+ Map, Integer> inDegree = new HashMap<>();
+
+ for (Class> clazz : entityClasses) {
+ dependents.putIfAbsent(clazz, new HashSet<>());
+ inDegree.putIfAbsent(clazz, 0);
+ }
+
+ // Build graph from JPA metamodel
+ for (Class> child : entityClasses) {
+ try {
+ EntityType> entityType = emf.getMetamodel().entity(child);
+ for (SingularAttribute, ?> attr : entityType.getSingularAttributes()) {
+ PersistentAttributeType pt = attr.getPersistentAttributeType();
+ if (pt == PersistentAttributeType.MANY_TO_ONE
+ || pt == PersistentAttributeType.ONE_TO_ONE) {
+ Class> parent = attr.getJavaType();
+ if (classSet.contains(parent) && !parent.equals(child)) {
+ // child depends on parent → edge: parent → child
+ dependents.get(parent).add(child);
+ inDegree.merge(child, 1, Integer::sum);
+ }
+ }
+ }
+ } catch (IllegalArgumentException e) {
+ // Entity not in metamodel; skip
+ log.warn("[Migration] Entity not found in JPA metamodel, skipping graph analysis: {}", child.getName());
+ }
+ }
+
+ // Kahn's BFS topological sort
+ Queue> queue = new ArrayDeque<>();
+ for (Class> clazz : entityClasses) {
+ if (inDegree.get(clazz) == 0) {
+ queue.add(clazz);
+ }
+ }
+
+ List> sorted = new ArrayList<>(entityClasses.size());
+ Set> visited = new HashSet<>();
+
+ while (!queue.isEmpty()) {
+ Class> current = queue.poll();
+ sorted.add(current);
+ visited.add(current);
+
+ for (Class> dependent : dependents.get(current)) {
+ int newDegree = inDegree.merge(dependent, -1, Integer::sum);
+ if (newDegree == 0) {
+ queue.add(dependent);
+ }
+ }
+ }
+
+ // Cycle fallback: append remaining unvisited nodes
+ if (sorted.size() < entityClasses.size()) {
+ log.warn("[Migration] Dependency graph has cycles; appending {} unresolved entities in original order",
+ entityClasses.size() - sorted.size());
+ for (Class> clazz : entityClasses) {
+ if (!visited.contains(clazz)) {
+ sorted.add(clazz);
+ }
+ }
+ }
+
+ log.debug("[Migration] Topological sort result: {}",
+ sorted.stream().map(Class::getSimpleName).toList());
+ return sorted;
+ }
+}
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/identity/KeepIdsIdentityMapper.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/identity/KeepIdsIdentityMapper.java
new file mode 100644
index 00000000..5c7e7165
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/identity/KeepIdsIdentityMapper.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.identity;
+
+import tools.dynamia.modules.saas.migration.api.IdentityMapper;
+import tools.dynamia.modules.saas.migration.api.IdentityStrategy;
+
+import java.util.Map;
+
+/**
+ * Identity mapper that preserves the original primary keys from the export file.
+ *
+ * Suitable for restoring a backup to a completely empty target database where
+ * ID conflicts are not expected. If the target database already contains rows
+ * with the same IDs, constraint violations will occur.
+ *
+ * @author Mario Serrano Leones
+ */
+public class KeepIdsIdentityMapper implements IdentityMapper {
+
+ @Override
+ public Object mapId(Object originalId, Class> entityClass) {
+ // Return the original ID — tell the pipeline to set it before persisting
+ return originalId;
+ }
+
+ @Override
+ public Object resolveReferenceId(Object originalRefId, Class> refClass,
+ Map> idMappings) {
+ // IDs are unchanged, so the reference ID from the file is already correct
+ return originalRefId;
+ }
+
+ @Override
+ public IdentityStrategy getStrategy() {
+ return IdentityStrategy.KEEP_IDS;
+ }
+}
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/identity/RegenerateIdsIdentityMapper.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/identity/RegenerateIdsIdentityMapper.java
new file mode 100644
index 00000000..1c55a25b
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/identity/RegenerateIdsIdentityMapper.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.identity;
+
+import tools.dynamia.modules.saas.migration.api.IdentityMapper;
+import tools.dynamia.modules.saas.migration.api.IdentityStrategy;
+
+import java.util.Map;
+
+/**
+ * Identity mapper that discards original IDs and lets JPA auto-generate new ones.
+ *
+ * During import, each entity is persisted without a pre-set ID so the JPA
+ * persistence provider assigns a fresh ID. After each {@code persist}, the mapping
+ * {@code originalId → newId} is recorded and used to resolve foreign-key references
+ * in subsequent entities.
+ *
+ *
This is the recommended strategy for cloning within the same database, where
+ * duplicate IDs would cause constraint violations.
+ *
+ * @author Mario Serrano Leones
+ */
+public class RegenerateIdsIdentityMapper implements IdentityMapper {
+
+ @Override
+ public Object mapId(Object originalId, Class> entityClass) {
+ // Return null → pipeline will clear the ID field and let JPA generate a new one
+ return null;
+ }
+
+ @Override
+ public Object resolveReferenceId(Object originalRefId, Class> refClass,
+ Map> idMappings) {
+ if (originalRefId == null) {
+ return null;
+ }
+ Map classMap = idMappings.get(refClass.getName());
+ if (classMap != null) {
+ Object mapped = classMap.get(originalRefId);
+ if (mapped != null) {
+ return mapped;
+ }
+ }
+ // Fallback: return original (may happen for references to entities not in the export,
+ // e.g., system-level entities shared across tenants)
+ return originalRefId;
+ }
+
+ @Override
+ public IdentityStrategy getStrategy() {
+ return IdentityStrategy.REGENERATE_IDS;
+ }
+}
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/pipeline/ExportConstants.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/pipeline/ExportConstants.java
new file mode 100644
index 00000000..2b318d19
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/pipeline/ExportConstants.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.pipeline;
+
+/**
+ * Constants shared between the export and import pipelines.
+ *
+ * @author Mario Serrano Leones
+ */
+public final class ExportConstants {
+
+ private ExportConstants() {
+ }
+
+ /** Current format version written to every export file. */
+ public static final String FORMAT_VERSION = "1";
+
+ /**
+ * Suffix appended to field names when serializing {@code @ManyToOne} /
+ * {@code @OneToOne} references. For example, a {@code category} field is
+ * exported as {@code category_ref_id} containing only the referenced entity's
+ * primary key value.
+ */
+ public static final String REF_ID_SUFFIX = "_ref_id";
+
+ /** Top-level JSON field containing the serialized AccountDTO. */
+ public static final String FIELD_ACCOUNT = "account";
+
+ /** Top-level JSON field containing the entity data map. */
+ public static final String FIELD_ENTITIES = "entities";
+
+ /** Top-level JSON field for the format version string. */
+ public static final String FIELD_VERSION = "version";
+
+ /** Top-level JSON field for the ISO-8601 export timestamp. */
+ public static final String FIELD_EXPORTED_AT = "exportedAt";
+
+ /** Top-level JSON field for the source account ID. */
+ public static final String FIELD_SOURCE_ACCOUNT_ID = "sourceAccountId";
+
+ /** Top-level JSON field for the identity strategy name. */
+ public static final String FIELD_IDENTITY_STRATEGY = "identityStrategy";
+}
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/pipeline/ExportPipeline.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/pipeline/ExportPipeline.java
new file mode 100644
index 00000000..46a7843b
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/pipeline/ExportPipeline.java
@@ -0,0 +1,324 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.pipeline;
+
+import tools.jackson.databind.ObjectMapper;
+import tools.jackson.core.JsonGenerator;
+import jakarta.persistence.EntityManagerFactory;
+import jakarta.persistence.metamodel.Attribute.PersistentAttributeType;
+import jakarta.persistence.metamodel.EntityType;
+import jakarta.persistence.metamodel.SingularAttribute;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Qualifier;
+import tools.dynamia.domain.jpa.JpaUtils;
+import tools.dynamia.domain.query.DataPaginator;
+import tools.dynamia.domain.query.QueryParameters;
+import tools.dynamia.domain.services.CrudService;
+import tools.dynamia.integration.sterotypes.Service;
+import tools.dynamia.modules.saas.api.ExportIgnore;
+import tools.dynamia.modules.saas.domain.Account;
+import tools.dynamia.modules.saas.migration.api.CancellationToken;
+import tools.dynamia.modules.saas.migration.api.MigrationException;
+import tools.dynamia.modules.saas.migration.api.MigrationProgress;
+import tools.dynamia.modules.saas.migration.api.MigrationProgressListener;
+import tools.dynamia.modules.saas.migration.api.AccountExportOptions;
+import tools.dynamia.modules.saas.migration.config.AccountMigrationProperties;
+import tools.dynamia.modules.saas.migration.discovery.AccountEntityDiscovery;
+import tools.dynamia.modules.saas.migration.graph.EntityDependencyGraph;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.Serializable;
+import java.lang.reflect.Field;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.zip.GZIPOutputStream;
+
+/**
+ * Streaming export pipeline.
+ *
+ * Writes tenant data to an {@link OutputStream} using Jackson's
+ * {@link JsonGenerator}. Entities are processed in topological order, paginated
+ * by chunks so that RAM usage is bounded regardless of dataset size.
+ *
+ *
Serialization rules per attribute type
+ *
+ * {@code BASIC} / {@code EMBEDDED} — value written directly.
+ * {@code MANY_TO_ONE} / {@code ONE_TO_ONE} — written as
+ * {@code {fieldName}_ref_id: }.
+ * {@code ONE_TO_MANY} / {@code MANY_TO_MANY} — skipped; child entities
+ * include their own references back to the parent.
+ *
+ *
+ * Fields annotated with {@link ExportIgnore} are silently skipped.
+ *
+ * @author Mario Serrano Leones
+ */
+@Service
+public class ExportPipeline {
+
+ private static final Logger log = LoggerFactory.getLogger(ExportPipeline.class);
+
+ private final EntityManagerFactory emf;
+ private final CrudService crudService;
+ private final AccountEntityDiscovery discovery;
+ private final EntityDependencyGraph dependencyGraph;
+ private final AccountMigrationProperties properties;
+ private final ObjectMapper objectMapper;
+
+ public ExportPipeline(EntityManagerFactory emf,
+ CrudService crudService,
+ AccountEntityDiscovery discovery,
+ EntityDependencyGraph dependencyGraph,
+ AccountMigrationProperties properties,
+ @Qualifier("migrationObjectMapper") ObjectMapper objectMapper) {
+ this.emf = emf;
+ this.crudService = crudService;
+ this.discovery = discovery;
+ this.dependencyGraph = dependencyGraph;
+ this.properties = properties;
+ this.objectMapper = objectMapper;
+ }
+
+ /**
+ * Exports all tenant data for {@code accountId} to {@code output}.
+ *
+ * @param accountId ID of the account to export
+ * @param output destination stream; ownership is NOT transferred — the caller must close it
+ * @param options export configuration
+ * @param listener optional progress callback
+ * @param token optional cancellation token
+ */
+ public void export(Long accountId,
+ OutputStream output,
+ AccountExportOptions options,
+ MigrationProgressListener listener,
+ CancellationToken token) {
+
+ Account account = crudService.find(Account.class, accountId);
+ if (account == null) {
+ throw new MigrationException("Account not found: " + accountId);
+ }
+
+ List> candidates = discovery.discoverExportableEntities();
+ List> ordered = dependencyGraph.topologicalSort(candidates);
+
+ // Pre-calculate approximate total for progress reporting
+ long totalRecords = 0;
+ for (Class> ec : ordered) {
+ if (!Account.class.equals(ec)) {
+ try {
+ totalRecords += crudService.count(ec, QueryParameters.with("accountId", accountId));
+ } catch (Exception e) {
+ log.debug("[Migration/Export] Could not count {}: {}", ec.getSimpleName(), e.getMessage());
+ }
+ }
+ }
+ totalRecords += 1; // +1 for the account itself
+
+ OutputStream target;
+ try {
+ target = options.isCompressionEnabled() ? new GZIPOutputStream(output) : output;
+ } catch (IOException e) {
+ throw new MigrationException("Failed to set up output stream", e);
+ }
+
+ try (JsonGenerator gen = objectMapper.createGenerator(target)) {
+
+ gen.writeStartObject();
+ gen.writeStringProperty(ExportConstants.FIELD_VERSION, ExportConstants.FORMAT_VERSION);
+ gen.writeStringProperty(ExportConstants.FIELD_EXPORTED_AT, LocalDateTime.now().toString());
+ gen.writeNumberProperty(ExportConstants.FIELD_SOURCE_ACCOUNT_ID, accountId);
+ gen.writeStringProperty(ExportConstants.FIELD_IDENTITY_STRATEGY,
+ options.getIdentityStrategy().name());
+
+ // Serialize AccountDTO as the tenant descriptor
+ gen.writeName(ExportConstants.FIELD_ACCOUNT);
+ objectMapper.writeValue(gen, account.toDTO());
+
+ // Entity section
+ gen.writeName(ExportConstants.FIELD_ENTITIES);
+ gen.writeStartObject();
+
+ long processed = 0;
+ for (Class> entityClass : ordered) {
+ if (token != null && token.isCancelled()) {
+ log.info("[Migration/Export] Cancelled at entity: {}", entityClass.getSimpleName());
+ break;
+ }
+
+ long count = exportEntityType(gen, entityClass, accountId, options, token);
+ processed += count;
+
+ if (listener != null) {
+ listener.onProgress(new MigrationProgress(processed, totalRecords,
+ "Exported " + entityClass.getSimpleName() + " (" + count + " records)"));
+ }
+ }
+
+ gen.writeEndObject(); // entities
+ gen.writeEndObject(); // root
+
+ if (options.isCompressionEnabled()) {
+ // Flush the GZIP stream
+ ((GZIPOutputStream) target).finish();
+ }
+
+ } catch (IOException e) {
+ throw new MigrationException("Export failed while writing JSON stream", e);
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Internal helpers
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private long exportEntityType(JsonGenerator gen, Class> entityClass, Long accountId,
+ AccountExportOptions options, CancellationToken token)
+ throws IOException {
+
+ long processed = 0;
+ int chunkSize = resolveChunkSize(options);
+
+ gen.writeName(entityClass.getName());
+ gen.writeStartArray();
+
+ try {
+ EntityType> entityType = emf.getMetamodel().entity(entityClass);
+
+ if (Account.class.equals(entityClass)) {
+ // Account is already serialized in the header; produce an empty array here
+ // to keep the format consistent (importers can find it if needed)
+ gen.writeEndArray();
+ return 0;
+ }
+
+ long totalCount = crudService.count(entityClass,
+ QueryParameters.with("accountId", accountId));
+ if (totalCount == 0) {
+ gen.writeEndArray();
+ return 0;
+ }
+
+ int totalPages = (int) Math.ceil((double) totalCount / chunkSize);
+
+ for (int page = 1; page <= totalPages; page++) {
+ if (token != null && token.isCancelled()) break;
+
+ DataPaginator paginator = new DataPaginator(totalCount, chunkSize, page);
+ QueryParameters qp = QueryParameters.with("accountId", accountId)
+ .paginate(paginator)
+ .orderBy("id", true);
+
+ @SuppressWarnings("unchecked")
+ List chunk = (List) crudService.find(entityClass, qp);
+ if (chunk == null || chunk.isEmpty()) break;
+
+ for (Object entity : chunk) {
+ writeEntity(gen, entity, entityType);
+ processed++;
+ }
+ }
+
+ } catch (IllegalArgumentException e) {
+ log.warn("[Migration/Export] Entity not in JPA metamodel, writing raw: {}", entityClass.getName());
+ }
+
+ gen.writeEndArray();
+ log.debug("[Migration/Export] {} records exported for {}", processed, entityClass.getSimpleName());
+ return processed;
+ }
+
+ private void writeEntity(JsonGenerator gen, Object entity, EntityType> entityType)
+ throws IOException {
+ gen.writeStartObject();
+
+ // Write ID explicitly
+ try {
+ Serializable id = JpaUtils.getJPAIdValue(entity);
+ gen.writeName("id");
+ gen.writePOJO(id);
+ } catch (Exception e) {
+ log.debug("[Migration/Export] Could not write ID for {}", entity.getClass().getSimpleName());
+ }
+
+ // Write all singular attributes
+ for (SingularAttribute, ?> attr : entityType.getSingularAttributes()) {
+ String name = attr.getName();
+ if ("id".equals(name)) continue; // already written
+
+ // Skip fields annotated with @ExportIgnore
+ if (hasExportIgnore(entityType.getJavaType(), name)) continue;
+
+ try {
+ Field field = findField(entityType.getJavaType(), name);
+ if (field == null) continue;
+ field.setAccessible(true);
+ Object value = field.get(entity);
+
+ PersistentAttributeType pt = attr.getPersistentAttributeType();
+
+ if (pt == PersistentAttributeType.MANY_TO_ONE
+ || pt == PersistentAttributeType.ONE_TO_ONE) {
+ if (value != null) {
+ Serializable refId = JpaUtils.getJPAIdValue(value);
+ gen.writeName(name + ExportConstants.REF_ID_SUFFIX);
+ gen.writePOJO(refId);
+ }
+ } else if (pt == PersistentAttributeType.ONE_TO_MANY
+ || pt == PersistentAttributeType.MANY_TO_MANY
+ || pt == PersistentAttributeType.ELEMENT_COLLECTION) {
+ // Skip collections — they are reconstructed via child entities
+ } else {
+ // BASIC or EMBEDDED
+ gen.writeName(name);
+ objectMapper.writeValue(gen, value);
+ }
+
+ } catch (IllegalAccessException e) {
+ log.debug("[Migration/Export] Cannot access field {} on {}: {}",
+ name, entityType.getJavaType().getSimpleName(), e.getMessage());
+ } catch (Exception e) {
+ log.debug("[Migration/Export] Skipping field {} due to: {}", name, e.getMessage());
+ }
+ }
+
+ gen.writeEndObject();
+ }
+
+ private boolean hasExportIgnore(Class> clazz, String fieldName) {
+ Field field = findField(clazz, fieldName);
+ return field != null && field.isAnnotationPresent(ExportIgnore.class);
+ }
+
+ private Field findField(Class> clazz, String fieldName) {
+ Class> current = clazz;
+ while (current != null && current != Object.class) {
+ try {
+ return current.getDeclaredField(fieldName);
+ } catch (NoSuchFieldException e) {
+ current = current.getSuperclass();
+ }
+ }
+ return null;
+ }
+
+ private int resolveChunkSize(AccountExportOptions options) {
+ int size = options.getChunkSize();
+ return size > 0 ? size : properties.getChunkSize();
+ }
+
+}
+
+
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/pipeline/ImportPipeline.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/pipeline/ImportPipeline.java
new file mode 100644
index 00000000..3ead6778
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/pipeline/ImportPipeline.java
@@ -0,0 +1,447 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.pipeline;
+
+import tools.jackson.core.JsonParser;
+import tools.jackson.core.JsonToken;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.ObjectMapper;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.EntityManagerFactory;
+import jakarta.persistence.PersistenceContext;
+import jakarta.persistence.metamodel.Attribute.PersistentAttributeType;
+import jakarta.persistence.metamodel.EntityType;
+import jakarta.persistence.metamodel.SingularAttribute;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+import tools.dynamia.domain.jpa.JpaUtils;
+import tools.dynamia.integration.sterotypes.Service;
+import tools.dynamia.modules.saas.migration.api.CancellationToken;
+import tools.dynamia.modules.saas.migration.api.IdentityMapper;
+import tools.dynamia.modules.saas.migration.api.IdentityStrategy;
+import tools.dynamia.modules.saas.migration.api.MigrationException;
+import tools.dynamia.modules.saas.migration.api.MigrationProgress;
+import tools.dynamia.modules.saas.migration.api.MigrationProgressListener;
+import tools.dynamia.modules.saas.migration.api.AccountImportOptions;
+import tools.dynamia.modules.saas.migration.config.AccountMigrationProperties;
+import tools.dynamia.modules.saas.migration.identity.KeepIdsIdentityMapper;
+import tools.dynamia.modules.saas.migration.identity.RegenerateIdsIdentityMapper;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.zip.GZIPInputStream;
+
+/**
+ * Streaming import pipeline.
+ *
+ * Reads the JSON export format produced by {@link ExportPipeline} using
+ * Jackson's {@link JsonParser} in streaming mode. Entities are persisted in
+ * topological order (guaranteed by the export format) using chunked transactions
+ * so no single transaction loads the full dataset.
+ *
+ *
For each entity record:
+ *
+ * Read JSON object as a {@link JsonNode}.
+ * Instantiate the entity class via its no-arg constructor.
+ * Set primitive / embedded fields from the JSON node.
+ * Resolve {@code _ref_id} references via the running {@code idMappings}
+ * table using the configured {@link IdentityMapper}.
+ * Set {@code accountId} to the target account.
+ * Persist the entity; record original→new ID mapping.
+ *
+ *
+ * @author Mario Serrano Leones
+ */
+@Service
+public class ImportPipeline {
+
+ private static final Logger log = LoggerFactory.getLogger(ImportPipeline.class);
+
+ @PersistenceContext
+ private EntityManager em;
+
+ /** Custom SPI mappers registered as Spring beans; queried before built-in defaults. */
+ @Autowired(required = false)
+ private List customMappers;
+
+ private final EntityManagerFactory emf;
+ private final AccountMigrationProperties properties;
+ private final ObjectMapper objectMapper;
+
+ public ImportPipeline(EntityManagerFactory emf,
+ AccountMigrationProperties properties,
+ @Qualifier("migrationObjectMapper") ObjectMapper objectMapper) {
+ this.emf = emf;
+ this.properties = properties;
+ this.objectMapper = objectMapper;
+ }
+
+ /**
+ * Imports all entity data from {@code input} into the target account.
+ *
+ * @param input export stream (auto-detected: GZIP or plain JSON)
+ * @param options import configuration
+ * @param listener optional progress callback
+ * @param token optional cancellation token
+ */
+ public void importTenant(InputStream input,
+ AccountImportOptions options,
+ MigrationProgressListener listener,
+ CancellationToken token) {
+ IdentityMapper identityMapper = resolveIdentityMapper(options);
+ Map> idMappings = new HashMap<>();
+
+ InputStream source;
+ try {
+ source = detectAndWrapGzip(input);
+ } catch (IOException e) {
+ throw new MigrationException("Failed to open input stream", e);
+ }
+
+ try (JsonParser parser = objectMapper.createParser(source)) {
+
+ expectToken(parser, JsonToken.START_OBJECT, "root object");
+ long totalProcessed = 0;
+
+ while (parser.nextToken() != JsonToken.END_OBJECT) {
+ if (token != null && token.isCancelled()) break;
+
+ String fieldName = parser.currentName();
+ parser.nextToken(); // move to value
+
+ switch (fieldName) {
+ case ExportConstants.FIELD_VERSION,
+ ExportConstants.FIELD_EXPORTED_AT,
+ ExportConstants.FIELD_SOURCE_ACCOUNT_ID,
+ ExportConstants.FIELD_IDENTITY_STRATEGY -> {
+ // Read and discard header metadata (validated if needed in future)
+ }
+ case ExportConstants.FIELD_ACCOUNT -> {
+ parser.skipChildren(); // Account handled externally
+ }
+ case ExportConstants.FIELD_ENTITIES -> {
+ totalProcessed = importEntitiesSection(
+ parser, options, identityMapper, idMappings, listener, token);
+ }
+ default -> parser.skipChildren();
+ }
+ }
+
+ if (listener != null) {
+ listener.onProgress(new MigrationProgress(
+ totalProcessed, totalProcessed, "Import complete"));
+ }
+
+ } catch (IOException e) {
+ throw new MigrationException("Import failed while reading JSON stream", e);
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Entities section
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private long importEntitiesSection(JsonParser parser,
+ AccountImportOptions options,
+ IdentityMapper identityMapper,
+ Map> idMappings,
+ MigrationProgressListener listener,
+ CancellationToken token) throws IOException {
+
+ expectToken(parser, JsonToken.START_OBJECT, "entities object");
+ long total = 0;
+
+ while (parser.nextToken() != JsonToken.END_OBJECT) {
+ if (token != null && token.isCancelled()) {
+ log.info("[Migration/Import] Cancelled");
+ break;
+ }
+
+ String entityClassName = parser.currentName();
+ parser.nextToken(); // START_ARRAY
+
+ try {
+ Class> entityClass = Class.forName(entityClassName);
+ long count = importEntityArray(
+ parser, entityClass, options, identityMapper, idMappings, listener, token);
+ total += count;
+ log.info("[Migration/Import] Imported {} records for {}", count, entityClass.getSimpleName());
+
+ if (listener != null) {
+ listener.onProgress(new MigrationProgress(total, 0,
+ "Imported " + entityClass.getSimpleName() + " (" + count + " records)"));
+ }
+
+ } catch (ClassNotFoundException e) {
+ log.warn("[Migration/Import] Entity class not found in classpath, skipping: {}", entityClassName);
+ parser.skipChildren();
+ }
+ }
+
+ return total;
+ }
+
+ private long importEntityArray(JsonParser parser,
+ Class> entityClass,
+ AccountImportOptions options,
+ IdentityMapper identityMapper,
+ Map> idMappings,
+ MigrationProgressListener listener,
+ CancellationToken token) throws IOException {
+
+ expectToken(parser, JsonToken.START_ARRAY, "entity array for " + entityClass.getSimpleName());
+
+ int chunkSize = options.getChunkSize() > 0 ? options.getChunkSize() : properties.getChunkSize();
+ List chunk = new ArrayList<>(chunkSize);
+ long total = 0;
+
+ while (parser.nextToken() != JsonToken.END_ARRAY) {
+ if (token != null && token.isCancelled()) break;
+
+ JsonNode node = objectMapper.readTree(parser);
+ chunk.add(node);
+
+ if (chunk.size() >= chunkSize) {
+ total += persistChunk(chunk, entityClass, options, identityMapper, idMappings);
+ chunk.clear();
+ }
+ }
+
+ if (!chunk.isEmpty()) {
+ total += persistChunk(chunk, entityClass, options, identityMapper, idMappings);
+ }
+
+ return total;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Chunk persistence (transactional boundary)
+ // ─────────────────────────────────────────────────────────────────────────
+
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public int persistChunk(List chunk,
+ Class> entityClass,
+ AccountImportOptions options,
+ IdentityMapper identityMapper,
+ Map> idMappings) {
+ int count = 0;
+ EntityType> entityType;
+ try {
+ entityType = emf.getMetamodel().entity(entityClass);
+ } catch (IllegalArgumentException e) {
+ log.warn("[Migration/Import] Entity not in JPA metamodel, skipping: {}", entityClass.getName());
+ return 0;
+ }
+
+ for (JsonNode node : chunk) {
+ try {
+ Object entity = deserializeEntity(node, entityClass, entityType,
+ options.getTargetAccountId(), identityMapper, idMappings);
+
+ Object originalId = readId(node);
+ Object mappedId = identityMapper.mapId(originalId, entityClass);
+
+ if (mappedId != null) {
+ // KEEP_IDS: set the original ID before persisting
+ setField(entity, "id", mappedId);
+ em.persist(entity);
+ } else {
+ // REGENERATE_IDS: clear ID and let JPA assign a new one
+ setField(entity, "id", null);
+ em.persist(entity);
+ em.flush(); // force ID generation
+ }
+
+ // Record mapping for downstream reference resolution
+ Object generatedId = JpaUtils.getJPAIdValue(entity);
+ if (originalId != null && generatedId != null) {
+ idMappings.computeIfAbsent(entityClass.getName(), k -> new HashMap<>())
+ .put(originalId, generatedId);
+ }
+
+ count++;
+
+ } catch (Exception e) {
+ if (options.isFailOnEntityError()) {
+ throw new MigrationException(
+ "Error persisting " + entityClass.getSimpleName(), e);
+ }
+ log.warn("[Migration/Import] Skipping entity due to error in {}: {}",
+ entityClass.getSimpleName(), e.getMessage());
+ log.debug("[Migration/Import] Stack trace:", e);
+ }
+ }
+ return count;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Entity deserialization
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private Object deserializeEntity(JsonNode node,
+ Class> entityClass,
+ EntityType> entityType,
+ Long targetAccountId,
+ IdentityMapper identityMapper,
+ Map> idMappings) throws Exception {
+
+ Object entity = entityClass.getDeclaredConstructor().newInstance();
+
+ for (SingularAttribute, ?> attr : entityType.getSingularAttributes()) {
+ String name = attr.getName();
+ if ("id".equals(name)) continue; // handled outside
+
+ PersistentAttributeType pt = attr.getPersistentAttributeType();
+
+ if ("accountId".equals(name)) {
+ setField(entity, "accountId", targetAccountId);
+ continue;
+ }
+
+ if (pt == PersistentAttributeType.MANY_TO_ONE
+ || pt == PersistentAttributeType.ONE_TO_ONE) {
+
+ String refKey = name + ExportConstants.REF_ID_SUFFIX;
+ JsonNode refIdNode = node.get(refKey);
+ if (refIdNode != null && !refIdNode.isNull()) {
+ Object originalRefId = refIdNode.asLong();
+ Class> refClass = attr.getJavaType();
+ Object resolvedId = identityMapper.resolveReferenceId(
+ originalRefId, refClass, idMappings);
+ if (resolvedId != null) {
+ try {
+ Object ref = em.getReference(refClass, coerceId(resolvedId, refClass));
+ setField(entity, name, ref);
+ } catch (Exception e) {
+ log.debug("[Migration/Import] Could not create reference proxy for {}={}: {}",
+ refKey, resolvedId, e.getMessage());
+ }
+ }
+ }
+
+ } else if (pt != PersistentAttributeType.ONE_TO_MANY
+ && pt != PersistentAttributeType.MANY_TO_MANY
+ && pt != PersistentAttributeType.ELEMENT_COLLECTION) {
+
+ JsonNode valueNode = node.get(name);
+ if (valueNode != null && !valueNode.isNull()) {
+ try {
+ Object value = objectMapper.treeToValue(valueNode, attr.getJavaType());
+ setField(entity, name, value);
+ } catch (Exception e) {
+ log.debug("[Migration/Import] Could not set field {}={}: {}",
+ name, valueNode, e.getMessage());
+ }
+ }
+ }
+ }
+
+ return entity;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Utilities
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private static void setField(Object entity, String fieldName, Object value) {
+ Class> clazz = entity.getClass();
+ while (clazz != null && clazz != Object.class) {
+ try {
+ Field field = clazz.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ field.set(entity, value);
+ return;
+ } catch (NoSuchFieldException e) {
+ clazz = clazz.getSuperclass();
+ } catch (IllegalAccessException e) {
+ log.debug("[Migration/Import] Cannot set field {}: {}", fieldName, e.getMessage());
+ return;
+ }
+ }
+ }
+
+ private static Object readId(JsonNode node) {
+ JsonNode idNode = node.get("id");
+ if (idNode == null || idNode.isNull()) return null;
+ if (idNode.isLong() || idNode.isInt()) return idNode.asLong();
+ return idNode.asText();
+ }
+
+ private static Object coerceId(Object id, Class> refClass) {
+ // If id is a String that looks like a Long, convert it
+ if (id instanceof String s) {
+ try {
+ return Long.parseLong(s);
+ } catch (NumberFormatException ignored) {
+ }
+ }
+ return id;
+ }
+
+ private static InputStream detectAndWrapGzip(InputStream in) throws IOException {
+ if (!in.markSupported()) {
+ return in; // cannot detect — use as-is
+ }
+ in.mark(2);
+ int b1 = in.read();
+ int b2 = in.read();
+ in.reset();
+ if (b1 == 0x1f && b2 == 0x8b) {
+ return new GZIPInputStream(in);
+ }
+ return in;
+ }
+
+ private IdentityMapper resolveIdentityMapper(AccountImportOptions options) {
+ IdentityStrategy strategy = options.getIdentityStrategy();
+ if (strategy == IdentityStrategy.UUID7) {
+ throw new MigrationException(
+ "IdentityStrategy.UUID7 is not yet supported (planned for v3). " +
+ "Use KEEP_IDS or REGENERATE_IDS.");
+ }
+ if (customMappers != null) {
+ for (IdentityMapper mapper : customMappers) {
+ if (mapper.getStrategy() == strategy) {
+ return mapper;
+ }
+ }
+ }
+ return switch (strategy) {
+ case KEEP_IDS -> new KeepIdsIdentityMapper();
+ default -> new RegenerateIdsIdentityMapper();
+ };
+ }
+
+ private static void expectToken(JsonParser parser, JsonToken expected, String context)
+ throws IOException {
+ JsonToken actual = parser.currentToken();
+ if (actual == null) {
+ parser.nextToken();
+ actual = parser.currentToken();
+ }
+ if (actual != expected) {
+ throw new MigrationException(
+ "Expected %s for %s but got %s".formatted(expected, context, actual));
+ }
+ }
+}
+
+
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/AccountMigrationJobServiceImpl.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/AccountMigrationJobServiceImpl.java
new file mode 100644
index 00000000..c2931e35
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/AccountMigrationJobServiceImpl.java
@@ -0,0 +1,395 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.services;
+
+import tools.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.web.multipart.MultipartFile;
+import tools.dynamia.domain.query.QueryParameters;
+import tools.dynamia.domain.services.CrudService;
+import tools.dynamia.integration.scheduling.SchedulerUtil;
+import tools.dynamia.integration.scheduling.TaskWithResult;
+import tools.dynamia.integration.sterotypes.Service;
+import tools.dynamia.modules.saas.migration.api.CancellationToken;
+import tools.dynamia.modules.saas.migration.api.IdentityStrategy;
+import tools.dynamia.modules.saas.migration.api.MigrationProgress;
+import tools.dynamia.modules.saas.migration.api.AccountCloneOptions;
+import tools.dynamia.modules.saas.migration.api.AccountExportOptions;
+import tools.dynamia.modules.saas.migration.api.AccountImportOptions;
+import tools.dynamia.modules.saas.migration.api.AccountMigrationJobDto;
+import tools.dynamia.modules.saas.migration.api.AccountMigrationJobService;
+import tools.dynamia.modules.saas.migration.api.AccountMigrationService;
+import tools.dynamia.modules.saas.migration.config.AccountMigrationProperties;
+import tools.dynamia.modules.saas.migration.domain.AccountJobStatus;
+import tools.dynamia.modules.saas.migration.domain.AccountJobType;
+import tools.dynamia.modules.saas.migration.domain.AccountMigrationJob;
+import tools.dynamia.modules.saas.migration.workers.CloneWorker;
+import tools.dynamia.modules.saas.migration.workers.ExportWorker;
+import tools.dynamia.modules.saas.migration.workers.ImportWorker;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Semaphore;
+import java.util.stream.Collectors;
+
+/**
+ * Default implementation of {@link AccountMigrationJobService}.
+ *
+ * Responsibilities:
+ *
+ * Persist {@link AccountMigrationJob} records in the database.
+ * Launch worker tasks via {@link SchedulerUtil#runWithResult(tools.dynamia.integration.scheduling.TaskWithResult)}
+ * on virtual threads.
+ * Update job status, progress and result path as the worker executes.
+ * Maintain an in-memory {@link CancellationToken} registry so running jobs can be cancelled.
+ *
+ *
+ * @author Mario Serrano Leones
+ */
+@Service
+public class AccountMigrationJobServiceImpl implements AccountMigrationJobService {
+
+ private static final Logger log = LoggerFactory.getLogger(AccountMigrationJobServiceImpl.class);
+ private static final DateTimeFormatter FILE_TS = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss");
+
+ /** In-memory token registry: jobUuid → CancellationToken. Cleaned up when job finishes. */
+ private final Map activeTokens = new ConcurrentHashMap<>();
+
+ private final CrudService crudService;
+ private final AccountMigrationService mobilityService;
+ private final AccountMigrationProperties properties;
+ private final ObjectMapper objectMapper;
+ private final Semaphore concurrencyLimit;
+
+ public AccountMigrationJobServiceImpl(CrudService crudService,
+ AccountMigrationService mobilityService,
+ AccountMigrationProperties properties,
+ @Qualifier("migrationObjectMapper") ObjectMapper objectMapper) {
+ this.crudService = crudService;
+ this.mobilityService = mobilityService;
+ this.properties = properties;
+ this.objectMapper = objectMapper;
+ this.concurrencyLimit = new Semaphore(Math.max(1, properties.getMaxConcurrentJobs()));
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Job creation
+ // ─────────────────────────────────────────────────────────────────────────
+
+ @Override
+ public AccountMigrationJobDto createExportJob(Long accountId, AccountExportOptions options) {
+ AccountMigrationJob job = createAndSaveJob(accountId, null, AccountJobType.EXPORT, options);
+ launchExportJob(job, accountId, options);
+ return toDto(job);
+ }
+
+ @Override
+ public AccountMigrationJobDto createBackupJob(Long accountId) {
+ AccountExportOptions options = new AccountExportOptions()
+ .compressionEnabled(properties.isCompressionEnabled())
+ .label("backup");
+ AccountMigrationJob job = createAndSaveJob(accountId, null, AccountJobType.BACKUP, options);
+ launchExportJob(job, accountId, options);
+ return toDto(job);
+ }
+
+ @Override
+ public AccountMigrationJobDto createImportJob(MultipartFile file, AccountImportOptions options) {
+ Path savedFile = saveUploadedFile(file, "import");
+ AccountMigrationJob job = createAndSaveJob(options.getTargetAccountId(), null, AccountJobType.IMPORT, options);
+ launchImportJob(job, savedFile, options);
+ return toDto(job);
+ }
+
+ @Override
+ public AccountMigrationJobDto createRestoreJob(Long accountId, MultipartFile file) {
+ Path savedFile = saveUploadedFile(file, "restore");
+ AccountImportOptions options = new AccountImportOptions()
+ .targetAccountId(accountId)
+ .identityStrategy(IdentityStrategy.KEEP_IDS);
+ AccountMigrationJob job = createAndSaveJob(accountId, null, AccountJobType.RESTORE, options);
+ launchImportJob(job, savedFile, options);
+ return toDto(job);
+ }
+
+ @Override
+ public AccountMigrationJobDto createCloneJob(AccountCloneOptions options) {
+ AccountMigrationJob job = createAndSaveJob(
+ options.getSourceAccountId(), options.getTargetAccountId(), AccountJobType.CLONE, options);
+ launchCloneJob(job, options);
+ return toDto(job);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Job query
+ // ─────────────────────────────────────────────────────────────────────────
+
+ @Override
+ public AccountMigrationJobDto getJob(String jobUuid) {
+ AccountMigrationJob job = findByUuid(jobUuid);
+ return job != null ? toDto(job) : null;
+ }
+
+ @Override
+ public AccountMigrationJob getJobEntity(String jobUuid) {
+ return findByUuid(jobUuid);
+ }
+
+ @Override
+ public List listJobs(Long accountId) {
+ QueryParameters qp = new QueryParameters()
+ .orderBy("createdAt", false);
+ if (accountId != null) {
+ qp.add("accountId", accountId);
+ }
+ return crudService.find(AccountMigrationJob.class, qp)
+ .stream()
+ .map(this::toDto)
+ .collect(Collectors.toList());
+ }
+
+ @Override
+ public void cancelJob(String jobUuid) {
+ CancellationToken token = activeTokens.get(jobUuid);
+ if (token != null) {
+ token.cancel("Cancelled by user request");
+ log.info("[Migration/Jobs] Cancellation requested for job {}", jobUuid);
+ } else {
+ log.warn("[Migration/Jobs] No active token found for job {} (already finished?)", jobUuid);
+ }
+ // Optimistically update status in DB
+ AccountMigrationJob job = findByUuid(jobUuid);
+ if (job != null && !job.isFinished()) {
+ crudService.executeWithinTransaction(() -> {
+ AccountMigrationJob j = crudService.find(AccountMigrationJob.class, job.getId());
+ if (j != null && !j.isFinished()) {
+ j.markCancelled("Cancellation requested");
+ crudService.update(j);
+ }
+ });
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Worker launchers
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private void launchExportJob(AccountMigrationJob job, Long accountId, AccountExportOptions options) {
+ CancellationToken token = CancellationToken.active();
+ activeTokens.put(job.getUuid(), token);
+ Path outputFile = buildOutputPath(job, options.isCompressionEnabled());
+ ExportWorker worker = new ExportWorker(
+ accountId, outputFile, options, mobilityService,
+ buildProgressListener(job), token);
+ scheduleWorker(job, worker, outputFile, null, token);
+ }
+
+ private void launchImportJob(AccountMigrationJob job, Path inputFile, AccountImportOptions options) {
+ CancellationToken token = CancellationToken.active();
+ activeTokens.put(job.getUuid(), token);
+ ImportWorker worker = new ImportWorker(
+ inputFile, options, mobilityService,
+ buildProgressListener(job), token);
+ scheduleWorker(job, worker, null, inputFile, token);
+ }
+
+ private void launchCloneJob(AccountMigrationJob job, AccountCloneOptions options) {
+ CancellationToken token = CancellationToken.active();
+ activeTokens.put(job.getUuid(), token);
+ CloneWorker worker = new CloneWorker(
+ options, mobilityService,
+ buildProgressListener(job), token);
+ scheduleWorker(job, worker, null, null, token);
+ }
+
+ /**
+ * Submits {@code worker} to a virtual thread, enforcing the configured
+ * {@link AccountMigrationProperties#getMaxConcurrentJobs()} limit via a semaphore.
+ * Workers that cannot immediately acquire a slot park the virtual thread
+ * (cheap) until a running job finishes.
+ *
+ * @param resultFile written to the job on success (may be null)
+ * @param cleanupPath deleted after the job completes (uploaded temp files; may be null)
+ */
+ private void scheduleWorker(AccountMigrationJob job,
+ TaskWithResult worker,
+ Path resultFile,
+ Path cleanupPath,
+ CancellationToken token) {
+ SchedulerUtil.runWithResult(new TaskWithResult(worker.getName() + "#queued") {
+ @Override
+ public Boolean doWorkWithResult() {
+ try {
+ concurrencyLimit.acquire();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException("Interrupted waiting for migration concurrency slot");
+ }
+ try {
+ return worker.doWorkWithResult();
+ } finally {
+ concurrencyLimit.release();
+ }
+ }
+ }).whenComplete((result, ex) -> {
+ activeTokens.remove(job.getUuid());
+ finalizeJob(job.getUuid(), ex, resultFile, token);
+ if (cleanupPath != null) {
+ try {
+ Files.deleteIfExists(cleanupPath);
+ } catch (IOException ignored) {
+ }
+ }
+ });
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Helpers
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private AccountMigrationJob createAndSaveJob(Long accountId, Long targetAccountId,
+ AccountJobType type, Object options) {
+ AccountMigrationJob job = new AccountMigrationJob();
+ job.setAccountId(accountId);
+ job.setTargetAccountId(targetAccountId);
+ job.setJobType(type);
+ job.setStatus(AccountJobStatus.PENDING);
+ if (options != null) {
+ try {
+ job.setOptionsJson(objectMapper.writeValueAsString(options));
+ } catch (Exception e) {
+ log.debug("[Migration/Jobs] Could not serialize options for {} job: {}", type, e.getMessage());
+ }
+ }
+ crudService.create(job);
+ log.info("[Migration/Jobs] Created job {} type={} account={}", job.getUuid(), type, accountId);
+ return job;
+ }
+
+ private void markRunning(String jobUuid) {
+ crudService.executeWithinTransaction(() -> {
+ AccountMigrationJob job = findByUuid(jobUuid);
+ if (job != null) {
+ job.markRunning();
+ crudService.update(job);
+ }
+ });
+ }
+
+ private void finalizeJob(String jobUuid, Throwable ex, Path resultFile, CancellationToken token) {
+ crudService.executeWithinTransaction(() -> {
+ AccountMigrationJob job = findByUuid(jobUuid);
+ if (job == null) return;
+
+ if (ex != null) {
+ job.markFailed(ex.getMessage() != null ? ex.getMessage() : ex.getClass().getSimpleName());
+ log.error("[Migration/Jobs] Job {} FAILED: {}", jobUuid, ex.getMessage());
+ } else if (token != null && token.isCancelled()) {
+ job.markCancelled(token.getReason());
+ log.info("[Migration/Jobs] Job {} CANCELLED: {}", jobUuid, token.getReason());
+ } else {
+ job.markCompleted();
+ if (resultFile != null) {
+ job.setResultPath(resultFile.toAbsolutePath().toString());
+ }
+ log.info("[Migration/Jobs] Job {} COMPLETED", jobUuid);
+ }
+ crudService.update(job);
+ });
+ }
+
+ private tools.dynamia.modules.saas.migration.api.MigrationProgressListener buildProgressListener(
+ AccountMigrationJob job) {
+ // Mark the job as RUNNING on first progress event, then persist progress updates
+ final boolean[] started = {false};
+ return (MigrationProgress p) -> {
+ if (!started[0]) {
+ started[0] = true;
+ markRunning(job.getUuid());
+ }
+ try {
+ crudService.executeWithinTransaction(() -> {
+ AccountMigrationJob j = findByUuid(job.getUuid());
+ if (j != null && !j.isFinished()) {
+ j.updateProgress(p.percentage() >= 0 ? p.percentage() : j.getProgress(),
+ p.message());
+ crudService.update(j);
+ }
+ });
+ } catch (Exception e) {
+ log.debug("[Migration/Jobs] Progress update error for {}: {}", job.getUuid(), e.getMessage());
+ }
+ };
+ }
+
+ private Path buildOutputPath(AccountMigrationJob job, boolean compressed) {
+ String ts = LocalDateTime.now().format(FILE_TS);
+ String fileName = "saas_export_" + job.getAccountId() + "_" + ts
+ + (compressed ? ".json.gz" : ".json");
+ return Paths.get(properties.getOutputDirectory(), fileName);
+ }
+
+ private Path saveUploadedFile(MultipartFile file, String prefix) {
+ try {
+ String ts = LocalDateTime.now().format(FILE_TS);
+ String ext = file.getOriginalFilename() != null
+ && file.getOriginalFilename().endsWith(".gz") ? ".json.gz" : ".json";
+ Path dest = Paths.get(properties.getOutputDirectory(), prefix + "_upload_" + ts + ext);
+ Files.createDirectories(dest.getParent());
+ try (InputStream in = file.getInputStream()) {
+ Files.copy(in, dest, StandardCopyOption.REPLACE_EXISTING);
+ }
+ return dest;
+ } catch (IOException e) {
+ throw new tools.dynamia.modules.saas.migration.api.MigrationException(
+ "Failed to save uploaded file", e);
+ }
+ }
+
+ private AccountMigrationJob findByUuid(String uuid) {
+ return crudService.findSingle(AccountMigrationJob.class,
+ QueryParameters.with("uuid", uuid));
+ }
+
+ private AccountMigrationJobDto toDto(AccountMigrationJob job) {
+ String downloadUrl = null;
+ if (job.getResultPath() != null) {
+ downloadUrl = "/api/saas/migration/jobs/" + job.getUuid() + "/download";
+ }
+ return new AccountMigrationJobDto(
+ job.getId(),
+ job.getUuid(),
+ job.getAccountId(),
+ job.getTargetAccountId(),
+ job.getJobType(),
+ job.getStatus(),
+ job.getProgress(),
+ job.getProgressMessage(),
+ job.getErrorMessage(),
+ downloadUrl,
+ job.getCreatedAt(),
+ job.getStartedAt(),
+ job.getFinishedAt()
+ );
+ }
+}
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/AccountMigrationServiceImpl.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/AccountMigrationServiceImpl.java
new file mode 100644
index 00000000..6a488007
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/AccountMigrationServiceImpl.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.services;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import tools.dynamia.integration.sterotypes.Service;
+import tools.dynamia.modules.saas.migration.api.CancellationToken;
+import tools.dynamia.modules.saas.migration.api.MigrationProgressListener;
+import tools.dynamia.modules.saas.migration.api.AccountCloneOptions;
+import tools.dynamia.modules.saas.migration.api.AccountExportOptions;
+import tools.dynamia.modules.saas.migration.api.AccountImportOptions;
+import tools.dynamia.modules.saas.migration.api.AccountMigrationService;
+import tools.dynamia.modules.saas.migration.pipeline.ExportPipeline;
+import tools.dynamia.modules.saas.migration.pipeline.ImportPipeline;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * Default implementation of {@link AccountMigrationService}.
+ *
+ * Delegates export to {@link ExportPipeline} and import to {@link ImportPipeline}.
+ * For clone operations, the export is buffered in-memory ({@link ByteArrayOutputStream})
+ * and then fed directly to the import pipeline — suitable for tenants with moderate
+ * data volumes (< ~100 MB uncompressed). For massive tenants, prefer the
+ * export-to-file + import-from-file job sequence.
+ *
+ * @author Mario Serrano Leones
+ */
+@Service
+public class AccountMigrationServiceImpl implements AccountMigrationService {
+
+ private static final Logger log = LoggerFactory.getLogger(AccountMigrationServiceImpl.class);
+
+ private final ExportPipeline exportPipeline;
+ private final ImportPipeline importPipeline;
+
+ public AccountMigrationServiceImpl(ExportPipeline exportPipeline,
+ ImportPipeline importPipeline) {
+ this.exportPipeline = exportPipeline;
+ this.importPipeline = importPipeline;
+ }
+
+ @Override
+ public void exportTenant(Long accountId,
+ OutputStream output,
+ AccountExportOptions options,
+ MigrationProgressListener listener,
+ CancellationToken token) {
+ log.info("[Migration] Starting export for accountId={}", accountId);
+ exportPipeline.export(accountId, output, options, listener, token);
+ log.info("[Migration] Export complete for accountId={}", accountId);
+ }
+
+ @Override
+ public void importTenant(InputStream input,
+ AccountImportOptions options,
+ MigrationProgressListener listener,
+ CancellationToken token) {
+ log.info("[Migration] Starting import for targetAccountId={}", options.getTargetAccountId());
+ importPipeline.importTenant(input, options, listener, token);
+ log.info("[Migration] Import complete for targetAccountId={}", options.getTargetAccountId());
+ }
+
+ @Override
+ public void cloneTenant(AccountCloneOptions options,
+ MigrationProgressListener listener,
+ CancellationToken token) {
+ Long source = options.getSourceAccountId();
+ Long target = options.getTargetAccountId();
+ log.info("[Migration] Starting clone {} → {}", source, target);
+
+ // ── Phase 1: Export to memory buffer ───────────────────────────────
+ AccountExportOptions exportOptions = new AccountExportOptions()
+ .chunkSize(options.getChunkSize())
+ .identityStrategy(options.getIdentityStrategy())
+ .label("clone-" + source + "->" + target);
+
+ ByteArrayOutputStream buffer = new ByteArrayOutputStream(8 * 1024 * 1024); // 8 MB initial
+ exportPipeline.export(source, buffer, exportOptions, progress -> {
+ if (listener != null) {
+ listener.onProgress(progress); // forward export progress
+ }
+ }, token);
+
+ if (token != null && token.isCancelled()) {
+ log.info("[Migration] Clone cancelled after export phase");
+ return;
+ }
+
+ // ── Phase 2: Import from buffer ────────────────────────────────────
+ AccountImportOptions importOptions = new AccountImportOptions()
+ .targetAccountId(target)
+ .chunkSize(options.getChunkSize())
+ .identityStrategy(options.getIdentityStrategy())
+ .failOnEntityError(options.isFailOnEntityError());
+
+ byte[] exported = buffer.toByteArray();
+ log.debug("[Migration] Clone buffer size: {} bytes", exported.length);
+
+ importPipeline.importTenant(
+ new ByteArrayInputStream(exported), importOptions, listener, token);
+
+ log.info("[Migration] Clone complete {} → {}", source, target);
+ }
+}
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/workers/CloneWorker.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/workers/CloneWorker.java
new file mode 100644
index 00000000..71c09f6f
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/workers/CloneWorker.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.workers;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import tools.dynamia.integration.scheduling.TaskWithResult;
+import tools.dynamia.modules.saas.migration.api.CancellationToken;
+import tools.dynamia.modules.saas.migration.api.MigrationProgressListener;
+import tools.dynamia.modules.saas.migration.api.AccountCloneOptions;
+import tools.dynamia.modules.saas.migration.api.AccountMigrationService;
+
+/**
+ * Background worker that executes a tenant clone operation
+ * (source account → target account, same system).
+ *
+ *
Export data is buffered in a {@code ByteArrayOutputStream} and then fed
+ * directly to the import pipeline — no disk I/O required.
+ * For tenants with very large datasets (> ~100 MB uncompressed), prefer the
+ * {@link ExportWorker} + {@link ImportWorker} sequence via a temporary file
+ * to avoid heap pressure.
+ *
+ *
Submitted to {@code SchedulerUtil.runWithResult()} and runs on a virtual thread.
+ *
+ * @author Mario Serrano Leones
+ */
+public class CloneWorker extends TaskWithResult {
+
+ private static final Logger log = LoggerFactory.getLogger(CloneWorker.class);
+
+ private final AccountCloneOptions options;
+ private final AccountMigrationService mobilityService;
+ private final MigrationProgressListener progressListener;
+ private final CancellationToken cancellationToken;
+
+ public CloneWorker(AccountCloneOptions options,
+ AccountMigrationService mobilityService,
+ MigrationProgressListener progressListener,
+ CancellationToken cancellationToken) {
+ super("CloneWorker-" + options.getSourceAccountId() + "->" + options.getTargetAccountId());
+ this.options = options;
+ this.mobilityService = mobilityService;
+ this.progressListener = progressListener;
+ this.cancellationToken = cancellationToken;
+ }
+
+ @Override
+ public Boolean doWorkWithResult() {
+ log.info("[Migration/Worker] Starting CLONE {} → {}",
+ options.getSourceAccountId(), options.getTargetAccountId());
+ try {
+ mobilityService.cloneTenant(options, progressListener, cancellationToken);
+ if (cancellationToken != null && cancellationToken.isCancelled()) {
+ log.info("[Migration/Worker] CLONE cancelled");
+ return false;
+ }
+ log.info("[Migration/Worker] CLONE completed {} → {}",
+ options.getSourceAccountId(), options.getTargetAccountId());
+ return true;
+ } catch (Exception e) {
+ log.error("[Migration/Worker] CLONE failed: {}", e.getMessage(), e);
+ throw new RuntimeException(e);
+ }
+ }
+}
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/workers/ExportWorker.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/workers/ExportWorker.java
new file mode 100644
index 00000000..fa01218a
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/workers/ExportWorker.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.workers;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import tools.dynamia.integration.scheduling.TaskWithResult;
+import tools.dynamia.modules.saas.migration.api.CancellationToken;
+import tools.dynamia.modules.saas.migration.api.MigrationProgressListener;
+import tools.dynamia.modules.saas.migration.api.AccountExportOptions;
+import tools.dynamia.modules.saas.migration.api.AccountMigrationService;
+
+import java.io.FileOutputStream;
+import java.io.OutputStream;
+import java.nio.file.Path;
+
+/**
+ * Background worker that executes a tenant export operation.
+ *
+ * This task is submitted to {@code SchedulerUtil.runWithResult()} and runs
+ * on a virtual thread. It calls {@link AccountMigrationService#exportTenant} and
+ * writes the result to the file path provided by the job service.
+ *
+ *
Returns {@code true} on success, {@code false} on failure or cancellation.
+ *
+ * @author Mario Serrano Leones
+ */
+public class ExportWorker extends TaskWithResult {
+
+ private static final Logger log = LoggerFactory.getLogger(ExportWorker.class);
+
+ private final Long accountId;
+ private final Path outputFile;
+ private final AccountExportOptions options;
+ private final AccountMigrationService mobilityService;
+ private final MigrationProgressListener progressListener;
+ private final CancellationToken cancellationToken;
+
+ public ExportWorker(Long accountId,
+ Path outputFile,
+ AccountExportOptions options,
+ AccountMigrationService mobilityService,
+ MigrationProgressListener progressListener,
+ CancellationToken cancellationToken) {
+ super("ExportWorker-account-" + accountId);
+ this.accountId = accountId;
+ this.outputFile = outputFile;
+ this.options = options;
+ this.mobilityService = mobilityService;
+ this.progressListener = progressListener;
+ this.cancellationToken = cancellationToken;
+ }
+
+ @Override
+ public Boolean doWorkWithResult() {
+ log.info("[Migration/Worker] Starting EXPORT for accountId={} → {}", accountId, outputFile);
+ try (OutputStream out = new FileOutputStream(outputFile.toFile())) {
+ mobilityService.exportTenant(accountId, out, options, progressListener, cancellationToken);
+ if (cancellationToken != null && cancellationToken.isCancelled()) {
+ log.info("[Migration/Worker] EXPORT cancelled for accountId={}", accountId);
+ return false;
+ }
+ log.info("[Migration/Worker] EXPORT completed for accountId={}", accountId);
+ return true;
+ } catch (Exception e) {
+ log.error("[Migration/Worker] EXPORT failed for accountId={}: {}", accountId, e.getMessage(), e);
+ throw new RuntimeException(e);
+ }
+ }
+}
+
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/workers/ImportWorker.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/workers/ImportWorker.java
new file mode 100644
index 00000000..9579f8b4
--- /dev/null
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/workers/ImportWorker.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.workers;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import tools.dynamia.integration.scheduling.TaskWithResult;
+import tools.dynamia.modules.saas.migration.api.CancellationToken;
+import tools.dynamia.modules.saas.migration.api.MigrationProgressListener;
+import tools.dynamia.modules.saas.migration.api.AccountImportOptions;
+import tools.dynamia.modules.saas.migration.api.AccountMigrationService;
+
+import java.io.BufferedInputStream;
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.nio.file.Path;
+
+/**
+ * Background worker that executes a tenant import operation from a file on disk.
+ *
+ * Submitted to {@code SchedulerUtil.runWithResult()} and runs on a virtual thread.
+ * Returns {@code true} on success, {@code false} on cancellation, or throws
+ * {@link RuntimeException} on failure.
+ *
+ * @author Mario Serrano Leones
+ */
+public class ImportWorker extends TaskWithResult {
+
+ private static final Logger log = LoggerFactory.getLogger(ImportWorker.class);
+
+ private final Path inputFile;
+ private final AccountImportOptions options;
+ private final AccountMigrationService mobilityService;
+ private final MigrationProgressListener progressListener;
+ private final CancellationToken cancellationToken;
+
+ public ImportWorker(Path inputFile,
+ AccountImportOptions options,
+ AccountMigrationService mobilityService,
+ MigrationProgressListener progressListener,
+ CancellationToken cancellationToken) {
+ super("ImportWorker-account-" + options.getTargetAccountId());
+ this.inputFile = inputFile;
+ this.options = options;
+ this.mobilityService = mobilityService;
+ this.progressListener = progressListener;
+ this.cancellationToken = cancellationToken;
+ }
+
+ @Override
+ public Boolean doWorkWithResult() {
+ log.info("[Migration/Worker] Starting IMPORT from {} → accountId={}",
+ inputFile, options.getTargetAccountId());
+ try (InputStream in = new BufferedInputStream(new FileInputStream(inputFile.toFile()))) {
+ mobilityService.importTenant(in, options, progressListener, cancellationToken);
+ if (cancellationToken != null && cancellationToken.isCancelled()) {
+ log.info("[Migration/Worker] IMPORT cancelled");
+ return false;
+ }
+ log.info("[Migration/Worker] IMPORT completed for accountId={}", options.getTargetAccountId());
+ return true;
+ } catch (Exception e) {
+ log.error("[Migration/Worker] IMPORT failed: {}", e.getMessage(), e);
+ throw new RuntimeException(e);
+ }
+ }
+}
+
diff --git a/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/AccountMigrationJobTest.java b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/AccountMigrationJobTest.java
new file mode 100644
index 00000000..a827e659
--- /dev/null
+++ b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/AccountMigrationJobTest.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration;
+
+import org.junit.Assert;
+import org.junit.Test;
+import tools.dynamia.modules.saas.migration.domain.AccountJobStatus;
+import tools.dynamia.modules.saas.migration.domain.AccountMigrationJob;
+
+public class AccountMigrationJobTest {
+
+ @Test
+ public void newJobIsInPendingStatus() {
+ AccountMigrationJob job = new AccountMigrationJob();
+ Assert.assertEquals(AccountJobStatus.PENDING, job.getStatus());
+ }
+
+ @Test
+ public void newJobIsNotFinished() {
+ Assert.assertFalse(new AccountMigrationJob().isFinished());
+ }
+
+ @Test
+ public void newJobHasUuid() {
+ AccountMigrationJob job = new AccountMigrationJob();
+ Assert.assertNotNull(job.getUuid());
+ Assert.assertFalse(job.getUuid().isEmpty());
+ }
+
+ @Test
+ public void markRunningTransitionsToRunning() {
+ AccountMigrationJob job = new AccountMigrationJob();
+ job.markRunning();
+
+ Assert.assertEquals(AccountJobStatus.RUNNING, job.getStatus());
+ Assert.assertNotNull(job.getStartedAt());
+ Assert.assertFalse(job.isFinished());
+ }
+
+ @Test
+ public void markCompletedSetsProgressTo100AndFinishedAt() {
+ AccountMigrationJob job = new AccountMigrationJob();
+ job.markRunning();
+ job.markCompleted();
+
+ Assert.assertEquals(AccountJobStatus.COMPLETED, job.getStatus());
+ Assert.assertEquals(100, job.getProgress());
+ Assert.assertNotNull(job.getFinishedAt());
+ Assert.assertTrue(job.isFinished());
+ }
+
+ @Test
+ public void markFailedStoresMessage() {
+ AccountMigrationJob job = new AccountMigrationJob();
+ job.markRunning();
+ job.markFailed("DB connection lost");
+
+ Assert.assertEquals(AccountJobStatus.FAILED, job.getStatus());
+ Assert.assertEquals("DB connection lost", job.getErrorMessage());
+ Assert.assertNotNull(job.getFinishedAt());
+ Assert.assertTrue(job.isFinished());
+ }
+
+ @Test
+ public void markCancelledStoresReason() {
+ AccountMigrationJob job = new AccountMigrationJob();
+ job.markRunning();
+ job.markCancelled("User requested cancellation");
+
+ Assert.assertEquals(AccountJobStatus.CANCELLED, job.getStatus());
+ Assert.assertEquals("User requested cancellation", job.getProgressMessage());
+ Assert.assertNotNull(job.getFinishedAt());
+ Assert.assertTrue(job.isFinished());
+ }
+
+ @Test
+ public void updateProgressClampsTo0_100Range() {
+ AccountMigrationJob job = new AccountMigrationJob();
+
+ job.updateProgress(-5, "below zero");
+ Assert.assertEquals(0, job.getProgress());
+
+ job.updateProgress(150, "above hundred");
+ Assert.assertEquals(100, job.getProgress());
+
+ job.updateProgress(42, "normal");
+ Assert.assertEquals(42, job.getProgress());
+ Assert.assertEquals("normal", job.getProgressMessage());
+ }
+
+ @Test
+ public void twoJobsHaveDifferentUuids() {
+ AccountMigrationJob a = new AccountMigrationJob();
+ AccountMigrationJob b = new AccountMigrationJob();
+ Assert.assertNotEquals(a.getUuid(), b.getUuid());
+ }
+}
diff --git a/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/CancellationTokenTest.java b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/CancellationTokenTest.java
new file mode 100644
index 00000000..a11cc3f9
--- /dev/null
+++ b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/CancellationTokenTest.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration;
+
+import org.junit.Assert;
+import org.junit.Test;
+import tools.dynamia.modules.saas.migration.api.CancellationToken;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+public class CancellationTokenTest {
+
+ @Test
+ public void newTokenIsNotCancelled() {
+ CancellationToken token = CancellationToken.active();
+ Assert.assertFalse(token.isCancelled());
+ Assert.assertNull(token.getReason());
+ }
+
+ @Test
+ public void cancelWithoutReasonUsesDefault() {
+ CancellationToken token = CancellationToken.active();
+ token.cancel();
+
+ Assert.assertTrue(token.isCancelled());
+ Assert.assertNotNull(token.getReason());
+ }
+
+ @Test
+ public void cancelWithReasonStoresReason() {
+ CancellationToken token = CancellationToken.active();
+ token.cancel("Timeout exceeded");
+
+ Assert.assertTrue(token.isCancelled());
+ Assert.assertEquals("Timeout exceeded", token.getReason());
+ }
+
+ @Test
+ public void cancelIsIdempotent() {
+ CancellationToken token = CancellationToken.active();
+ token.cancel("first");
+ token.cancel("second");
+
+ Assert.assertTrue(token.isCancelled());
+ Assert.assertEquals("second", token.getReason());
+ }
+
+ @Test
+ public void cancelFromOtherThreadIsVisibleImmediately() throws InterruptedException {
+ CancellationToken token = CancellationToken.active();
+ AtomicBoolean seen = new AtomicBoolean(false);
+ CountDownLatch latch = new CountDownLatch(1);
+
+ Thread t = Thread.ofVirtual().start(() -> {
+ token.cancel("from-thread");
+ latch.countDown();
+ });
+
+ latch.await(2, TimeUnit.SECONDS);
+ seen.set(token.isCancelled());
+ t.join(1000);
+
+ Assert.assertTrue("Cancel from another thread must be visible", seen.get());
+ }
+}
diff --git a/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/api/OptionsFluentBuilderTest.java b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/api/OptionsFluentBuilderTest.java
new file mode 100644
index 00000000..16633aa9
--- /dev/null
+++ b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/api/OptionsFluentBuilderTest.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.api;
+
+import tools.jackson.databind.ObjectMapper;
+import tools.jackson.databind.json.JsonMapper;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Tests for the fluent builder APIs on options classes and their Jackson serialization
+ * (exercising the options_json feature added to AccountMigrationJob).
+ */
+public class OptionsFluentBuilderTest {
+
+ private ObjectMapper objectMapper;
+
+ @Before
+ public void setUp() {
+ objectMapper = JsonMapper.builder()
+ .build();
+ }
+
+ // ─── AccountExportOptions ────────────────────────────────────────────────
+
+ @Test
+ public void exportOptionsDefaults() {
+ AccountExportOptions opts = new AccountExportOptions();
+ Assert.assertEquals(500, opts.getChunkSize());
+ Assert.assertFalse(opts.isCompressionEnabled());
+ Assert.assertEquals(IdentityStrategy.KEEP_IDS, opts.getIdentityStrategy());
+ }
+
+ @Test
+ public void exportOptionsFluentBuilder() {
+ AccountExportOptions opts = new AccountExportOptions()
+ .chunkSize(200)
+ .compressionEnabled(true)
+ .identityStrategy(IdentityStrategy.REGENERATE_IDS)
+ .label("my-export");
+
+ Assert.assertEquals(200, opts.getChunkSize());
+ Assert.assertTrue(opts.isCompressionEnabled());
+ Assert.assertEquals(IdentityStrategy.REGENERATE_IDS, opts.getIdentityStrategy());
+ Assert.assertEquals("my-export", opts.getLabel());
+ }
+
+ @Test
+ public void exportOptionsIsJsonSerializable() throws Exception {
+ AccountExportOptions opts = new AccountExportOptions()
+ .chunkSize(100)
+ .compressionEnabled(true)
+ .identityStrategy(IdentityStrategy.KEEP_IDS);
+
+ String json = objectMapper.writeValueAsString(opts);
+ Assert.assertNotNull(json);
+ Assert.assertTrue(json.contains("chunkSize"));
+ Assert.assertTrue(json.contains("KEEP_IDS"));
+
+ AccountExportOptions roundtrip = objectMapper.readValue(json, AccountExportOptions.class);
+ Assert.assertEquals(100, roundtrip.getChunkSize());
+ Assert.assertTrue(roundtrip.isCompressionEnabled());
+ }
+
+ // ─── AccountImportOptions ────────────────────────────────────────────────
+
+ @Test
+ public void importOptionsDefaults() {
+ AccountImportOptions opts = new AccountImportOptions();
+ Assert.assertNull(opts.getTargetAccountId());
+ Assert.assertEquals(IdentityStrategy.REGENERATE_IDS, opts.getIdentityStrategy());
+ Assert.assertEquals(500, opts.getChunkSize());
+ Assert.assertFalse(opts.isFailOnEntityError());
+ }
+
+ @Test
+ public void importOptionsFluentBuilder() {
+ AccountImportOptions opts = new AccountImportOptions()
+ .targetAccountId(42L)
+ .identityStrategy(IdentityStrategy.KEEP_IDS)
+ .chunkSize(250)
+ .failOnEntityError(true);
+
+ Assert.assertEquals(42L, (long) opts.getTargetAccountId());
+ Assert.assertEquals(IdentityStrategy.KEEP_IDS, opts.getIdentityStrategy());
+ Assert.assertEquals(250, opts.getChunkSize());
+ Assert.assertTrue(opts.isFailOnEntityError());
+ }
+
+ @Test
+ public void importOptionsIsJsonSerializable() throws Exception {
+ AccountImportOptions opts = new AccountImportOptions()
+ .targetAccountId(7L)
+ .identityStrategy(IdentityStrategy.REGENERATE_IDS);
+
+ String json = objectMapper.writeValueAsString(opts);
+ Assert.assertNotNull(json);
+ Assert.assertTrue(json.contains("targetAccountId"));
+ Assert.assertTrue(json.contains("REGENERATE_IDS"));
+
+ AccountImportOptions roundtrip = objectMapper.readValue(json, AccountImportOptions.class);
+ Assert.assertEquals(7L, (long) roundtrip.getTargetAccountId());
+ }
+
+ // ─── AccountCloneOptions ─────────────────────────────────────────────────
+
+ @Test
+ public void cloneOptionsIsJsonSerializable() throws Exception {
+ AccountCloneOptions opts = new AccountCloneOptions();
+ opts.setSourceAccountId(1L);
+ opts.setTargetAccountId(2L);
+
+ String json = objectMapper.writeValueAsString(opts);
+ Assert.assertNotNull(json);
+ Assert.assertTrue(json.contains("sourceAccountId") || json.contains("1"));
+ }
+}
diff --git a/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/config/AccountMigrationPropertiesTest.java b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/config/AccountMigrationPropertiesTest.java
new file mode 100644
index 00000000..64dfb343
--- /dev/null
+++ b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/config/AccountMigrationPropertiesTest.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.config;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.util.concurrent.Semaphore;
+
+public class AccountMigrationPropertiesTest {
+
+ @Test
+ public void defaultChunkSizeIs500() {
+ Assert.assertEquals(500, new AccountMigrationProperties().getChunkSize());
+ }
+
+ @Test
+ public void defaultCompressionIsDisabled() {
+ Assert.assertFalse(new AccountMigrationProperties().isCompressionEnabled());
+ }
+
+ @Test
+ public void defaultMaxConcurrentJobsIs5() {
+ Assert.assertEquals(5, new AccountMigrationProperties().getMaxConcurrentJobs());
+ }
+
+ @Test
+ public void defaultFailOnEntityErrorIsFalse() {
+ Assert.assertFalse(new AccountMigrationProperties().isFailOnEntityError());
+ }
+
+ @Test
+ public void defaultOutputDirectoryContainsTmpdir() {
+ String dir = new AccountMigrationProperties().getOutputDirectory();
+ Assert.assertNotNull(dir);
+ Assert.assertTrue("outputDirectory should use system tmpdir",
+ dir.contains(System.getProperty("java.io.tmpdir").replace("\\", "/")));
+ }
+
+ @Test
+ public void semaphoreInitializedFromMaxConcurrentJobs() {
+ AccountMigrationProperties props = new AccountMigrationProperties();
+ props.setMaxConcurrentJobs(3);
+
+ // Simulate the service constructor logic
+ Semaphore semaphore = new Semaphore(Math.max(1, props.getMaxConcurrentJobs()));
+ Assert.assertEquals(3, semaphore.availablePermits());
+ }
+
+ @Test
+ public void semaphoreFloorIsOneEvenIfMaxIsZeroOrNegative() {
+ // The service uses Math.max(1, maxConcurrentJobs) to avoid a 0-permit semaphore
+ Assert.assertEquals(1, Math.max(1, 0));
+ Assert.assertEquals(1, Math.max(1, -5));
+ Assert.assertEquals(2, Math.max(1, 2));
+ }
+}
diff --git a/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/graph/EntityDependencyGraphTest.java b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/graph/EntityDependencyGraphTest.java
new file mode 100644
index 00000000..c8ef9e2d
--- /dev/null
+++ b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/graph/EntityDependencyGraphTest.java
@@ -0,0 +1,182 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.graph;
+
+import jakarta.persistence.EntityManagerFactory;
+import jakarta.persistence.metamodel.Attribute.PersistentAttributeType;
+import jakarta.persistence.metamodel.EntityType;
+import jakarta.persistence.metamodel.Metamodel;
+import jakarta.persistence.metamodel.SingularAttribute;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import java.util.List;
+import java.util.Set;
+
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests for {@link EntityDependencyGraph#topologicalSort(List)}.
+ *
+ * Entity model used across tests:
+ *
+ * Account ←── Order ←── OrderItem ──→ Product ←── Category (no deps)
+ *
+ * Expected topological order: {Account, Category} before {Order, Product} before {OrderItem}.
+ *
+ * Raw-typed mocks are required to work around the complex JPA generics on
+ * {@code ManagedType.getSingularAttributes()} which returns
+ * {@code Set>}.
+ */
+@SuppressWarnings({"unchecked", "rawtypes"})
+@RunWith(MockitoJUnitRunner.class)
+public class EntityDependencyGraphTest {
+
+ // Marker classes used as stand-ins for real JPA entities
+ static class Account {}
+ static class Category {}
+ static class Product {}
+ static class Order {}
+ static class OrderItem {}
+
+ @Mock private EntityManagerFactory emf;
+ @Mock private Metamodel metamodel;
+
+ // Raw EntityType mocks avoid JPA wildcard inference problems
+ private EntityType accountType;
+ private EntityType categoryType;
+ private EntityType productType;
+ private EntityType orderType;
+ private EntityType orderItemType;
+
+ private EntityDependencyGraph graph;
+
+ @Before
+ public void setUp() {
+ accountType = mock(EntityType.class);
+ categoryType = mock(EntityType.class);
+ productType = mock(EntityType.class);
+ orderType = mock(EntityType.class);
+ orderItemType = mock(EntityType.class);
+
+ when(emf.getMetamodel()).thenReturn(metamodel);
+
+ // Account: no singular attributes pointing to entities in our set
+ when(metamodel.entity(Account.class)).thenReturn(accountType);
+ doReturn(Set.of()).when(accountType).getSingularAttributes();
+
+ // Category: no dependencies
+ when(metamodel.entity(Category.class)).thenReturn(categoryType);
+ doReturn(Set.of()).when(categoryType).getSingularAttributes();
+
+ // Product depends on Category (ManyToOne)
+ SingularAttribute productCategory = attrOf(PersistentAttributeType.MANY_TO_ONE, Category.class);
+ when(metamodel.entity(Product.class)).thenReturn(productType);
+ doReturn(Set.of(productCategory)).when(productType).getSingularAttributes();
+
+ // Order depends on Account (ManyToOne)
+ SingularAttribute orderAccount = attrOf(PersistentAttributeType.MANY_TO_ONE, Account.class);
+ when(metamodel.entity(Order.class)).thenReturn(orderType);
+ doReturn(Set.of(orderAccount)).when(orderType).getSingularAttributes();
+
+ // OrderItem depends on Order (ManyToOne) and Product (ManyToOne)
+ SingularAttribute itemOrder = attrOf(PersistentAttributeType.MANY_TO_ONE, Order.class);
+ SingularAttribute itemProduct = attrOf(PersistentAttributeType.MANY_TO_ONE, Product.class);
+ when(metamodel.entity(OrderItem.class)).thenReturn(orderItemType);
+ doReturn(Set.of(itemOrder, itemProduct)).when(orderItemType).getSingularAttributes();
+
+ graph = new EntityDependencyGraph(emf);
+ }
+
+ @Test
+ public void parentsAppearBeforeChildrenInOutput() {
+ List> input = List.of(Account.class, Category.class, Product.class,
+ Order.class, OrderItem.class);
+ List> sorted = graph.topologicalSort(input);
+
+ int idxAccount = sorted.indexOf(Account.class);
+ int idxCategory = sorted.indexOf(Category.class);
+ int idxProduct = sorted.indexOf(Product.class);
+ int idxOrder = sorted.indexOf(Order.class);
+ int idxItem = sorted.indexOf(OrderItem.class);
+
+ Assert.assertTrue("Account before Order", idxAccount < idxOrder);
+ Assert.assertTrue("Account before OrderItem", idxAccount < idxItem);
+ Assert.assertTrue("Category before Product", idxCategory < idxProduct);
+ Assert.assertTrue("Order before OrderItem", idxOrder < idxItem);
+ Assert.assertTrue("Product before OrderItem", idxProduct < idxItem);
+ }
+
+ @Test
+ public void allInputClassesArePresent() {
+ List> input = List.of(Account.class, Category.class, Product.class,
+ Order.class, OrderItem.class);
+ List> sorted = graph.topologicalSort(input);
+
+ Assert.assertEquals(input.size(), sorted.size());
+ Assert.assertTrue(sorted.containsAll(input));
+ }
+
+ @Test
+ public void emptyInputReturnsEmptyList() {
+ Assert.assertTrue(graph.topologicalSort(List.of()).isEmpty());
+ }
+
+ @Test
+ public void nullInputReturnsEmptyList() {
+ Assert.assertTrue(graph.topologicalSort(null).isEmpty());
+ }
+
+ @Test
+ public void singleEntityWithNoDepsIsReturnedAsIs() {
+ List> sorted = graph.topologicalSort(List.of(Account.class));
+ Assert.assertEquals(1, sorted.size());
+ Assert.assertEquals(Account.class, sorted.get(0));
+ }
+
+ @Test
+ public void oneToOneRelationAlsoCreatesEdge() {
+ SingularAttribute oneToOne = attrOf(PersistentAttributeType.ONE_TO_ONE, Account.class);
+ doReturn(Set.of(oneToOne)).when(productType).getSingularAttributes();
+
+ List> sorted = graph.topologicalSort(List.of(Account.class, Product.class));
+ Assert.assertTrue("Account before Product (ONE_TO_ONE)",
+ sorted.indexOf(Account.class) < sorted.indexOf(Product.class));
+ }
+
+ @Test
+ public void basicAttributeDoesNotCreateDependencyEdge() {
+ // A BASIC attr whose javaType happens to be another entity class must not create an edge
+ SingularAttribute basic = attrOf(PersistentAttributeType.BASIC, Account.class);
+ doReturn(Set.of(basic)).when(categoryType).getSingularAttributes();
+
+ // Both are present, no ordering constraint — both orderings are valid
+ List> sorted = graph.topologicalSort(List.of(Account.class, Category.class));
+ Assert.assertEquals(2, sorted.size());
+ Assert.assertTrue(sorted.contains(Account.class));
+ Assert.assertTrue(sorted.contains(Category.class));
+ }
+
+ // ─── Helper ──────────────────────────────────────────────────────────────
+
+ private static SingularAttribute attrOf(PersistentAttributeType type, Class> javaType) {
+ SingularAttribute attr = mock(SingularAttribute.class);
+ when(attr.getPersistentAttributeType()).thenReturn(type);
+ when(attr.getJavaType()).thenReturn(javaType);
+ return attr;
+ }
+}
diff --git a/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/identity/KeepIdsIdentityMapperTest.java b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/identity/KeepIdsIdentityMapperTest.java
new file mode 100644
index 00000000..a3b0fd77
--- /dev/null
+++ b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/identity/KeepIdsIdentityMapperTest.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.identity;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import tools.dynamia.modules.saas.migration.api.IdentityStrategy;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class KeepIdsIdentityMapperTest {
+
+ private KeepIdsIdentityMapper mapper;
+
+ @Before
+ public void setUp() {
+ mapper = new KeepIdsIdentityMapper();
+ }
+
+ @Test
+ public void strategyIsKeepIds() {
+ Assert.assertEquals(IdentityStrategy.KEEP_IDS, mapper.getStrategy());
+ }
+
+ @Test
+ public void mapIdReturnsOriginalId() {
+ Assert.assertEquals(42L, mapper.mapId(42L, String.class));
+ Assert.assertEquals("uuid-123", mapper.mapId("uuid-123", Object.class));
+ }
+
+ @Test
+ public void mapIdWithNullReturnsNull() {
+ Assert.assertNull(mapper.mapId(null, String.class));
+ }
+
+ @Test
+ public void resolveReferenceIdReturnsOriginalRefIdIgnoringMap() {
+ Map> idMappings = new HashMap<>();
+ idMappings.put(String.class.getName(), Map.of(1L, 999L));
+
+ // KEEP_IDS: the ref ID from the file is the correct ID in the target DB
+ Object resolved = mapper.resolveReferenceId(1L, String.class, idMappings);
+ Assert.assertEquals(1L, resolved);
+ }
+
+ @Test
+ public void resolveReferenceIdWithNullRefIdReturnsNull() {
+ Assert.assertNull(mapper.resolveReferenceId(null, String.class, new HashMap<>()));
+ }
+}
diff --git a/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/identity/RegenerateIdsIdentityMapperTest.java b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/identity/RegenerateIdsIdentityMapperTest.java
new file mode 100644
index 00000000..10703838
--- /dev/null
+++ b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/identity/RegenerateIdsIdentityMapperTest.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.identity;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import tools.dynamia.modules.saas.migration.api.IdentityStrategy;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class RegenerateIdsIdentityMapperTest {
+
+ private RegenerateIdsIdentityMapper mapper;
+
+ @Before
+ public void setUp() {
+ mapper = new RegenerateIdsIdentityMapper();
+ }
+
+ @Test
+ public void strategyIsRegenerateIds() {
+ Assert.assertEquals(IdentityStrategy.REGENERATE_IDS, mapper.getStrategy());
+ }
+
+ @Test
+ public void mapIdAlwaysReturnsNull() {
+ Assert.assertNull(mapper.mapId(1L, String.class));
+ Assert.assertNull(mapper.mapId(99999L, Object.class));
+ Assert.assertNull(mapper.mapId(null, String.class));
+ }
+
+ @Test
+ public void resolveReferenceIdLookupsFromIdMappings() {
+ Map> idMappings = new HashMap<>();
+ idMappings.put(String.class.getName(), Map.of(10L, 501L));
+
+ Object resolved = mapper.resolveReferenceId(10L, String.class, idMappings);
+ Assert.assertEquals(501L, resolved);
+ }
+
+ @Test
+ public void resolveReferenceIdFallsBackToOriginalWhenNotMapped() {
+ // Entity not in idMappings (e.g. system-level shared entity)
+ Map> idMappings = new HashMap<>();
+
+ Object resolved = mapper.resolveReferenceId(77L, String.class, idMappings);
+ Assert.assertEquals(77L, resolved);
+ }
+
+ @Test
+ public void resolveReferenceIdWithNullRefIdReturnsNull() {
+ Assert.assertNull(mapper.resolveReferenceId(null, String.class, new HashMap<>()));
+ }
+
+ @Test
+ public void resolveReferenceIdFallsBackWhenClassKeyExistsButIdMissing() {
+ // Class is in the map but this specific originalId isn't recorded yet
+ Map> idMappings = new HashMap<>();
+ Map classMap = new HashMap<>();
+ classMap.put(1L, 100L);
+ idMappings.put(String.class.getName(), classMap);
+
+ // originalRefId=99 not in classMap → fallback to original
+ Object resolved = mapper.resolveReferenceId(99L, String.class, idMappings);
+ Assert.assertEquals(99L, resolved);
+ }
+}
diff --git a/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/pipeline/ExportConstantsTest.java b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/pipeline/ExportConstantsTest.java
new file mode 100644
index 00000000..70806367
--- /dev/null
+++ b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/pipeline/ExportConstantsTest.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.pipeline;
+
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.ObjectMapper;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+
+/**
+ * Tests that the JSON format constants used by ExportPipeline and ImportPipeline
+ * match the documented export file format (ARCHITECTURE.md §7).
+ */
+public class ExportConstantsTest {
+
+ @Test
+ public void formatVersionIsOne() {
+ Assert.assertEquals("1", ExportConstants.FORMAT_VERSION);
+ }
+
+ @Test
+ public void refIdSuffixIsUnderscoredRefId() {
+ Assert.assertEquals("_ref_id", ExportConstants.REF_ID_SUFFIX);
+ }
+
+ @Test
+ public void fieldNamesMatchArchitectureSpec() {
+ Assert.assertEquals("version", ExportConstants.FIELD_VERSION);
+ Assert.assertEquals("exportedAt", ExportConstants.FIELD_EXPORTED_AT);
+ Assert.assertEquals("sourceAccountId", ExportConstants.FIELD_SOURCE_ACCOUNT_ID);
+ Assert.assertEquals("identityStrategy", ExportConstants.FIELD_IDENTITY_STRATEGY);
+ Assert.assertEquals("account", ExportConstants.FIELD_ACCOUNT);
+ Assert.assertEquals("entities", ExportConstants.FIELD_ENTITIES);
+ }
+
+ @Test
+ public void refIdSuffixProducesCorrectFieldName() {
+ // e.g. "category" field becomes "category_ref_id" in the JSON
+ String refField = "category" + ExportConstants.REF_ID_SUFFIX;
+ Assert.assertEquals("category_ref_id", refField);
+ }
+
+ @Test
+ public void minimalExportJsonStructureIsValid() throws IOException {
+ // Build the minimal JSON skeleton that ImportPipeline expects
+ ObjectMapper mapper = new ObjectMapper();
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ var gen = mapper.createGenerator(out);
+
+ gen.writeStartObject();
+ gen.writeStringProperty(ExportConstants.FIELD_VERSION, ExportConstants.FORMAT_VERSION);
+ gen.writeStringProperty(ExportConstants.FIELD_EXPORTED_AT, "2026-06-15T10:00:00");
+ gen.writeNumberProperty(ExportConstants.FIELD_SOURCE_ACCOUNT_ID, 1L);
+ gen.writeStringProperty(ExportConstants.FIELD_IDENTITY_STRATEGY, "KEEP_IDS");
+ gen.writeName(ExportConstants.FIELD_ACCOUNT); gen.writeStartObject();
+ gen.writeEndObject();
+ gen.writeName(ExportConstants.FIELD_ENTITIES); gen.writeStartObject();
+ gen.writeEndObject();
+ gen.writeEndObject();
+ gen.close();
+
+ JsonNode root = mapper.readTree(out.toByteArray());
+ Assert.assertEquals("1", root.get(ExportConstants.FIELD_VERSION).asText());
+ Assert.assertEquals(1L, root.get(ExportConstants.FIELD_SOURCE_ACCOUNT_ID).asLong());
+ Assert.assertEquals("KEEP_IDS", root.get(ExportConstants.FIELD_IDENTITY_STRATEGY).asText());
+ Assert.assertTrue(root.has(ExportConstants.FIELD_ACCOUNT));
+ Assert.assertTrue(root.has(ExportConstants.FIELD_ENTITIES));
+ }
+}
diff --git a/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/pipeline/GzipStreamTest.java b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/pipeline/GzipStreamTest.java
new file mode 100644
index 00000000..247bd1bf
--- /dev/null
+++ b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/pipeline/GzipStreamTest.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.pipeline;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.zip.GZIPInputStream;
+import java.util.zip.GZIPOutputStream;
+
+/**
+ * Verifies the GZIP auto-detection contract relied on by {@code ImportPipeline.detectAndWrapGzip()}.
+ *
+ * The fix (P1) requires that {@code ImportWorker} wraps the {@code FileInputStream} with a
+ * {@code BufferedInputStream} before passing it to the pipeline, so that {@code mark()} is
+ * supported and GZIP detection can read the magic bytes and reset the position.
+ */
+public class GzipStreamTest {
+
+ @Test
+ public void bufferedInputStreamSupportsMark() {
+ InputStream raw = new ByteArrayInputStream(new byte[]{1, 2, 3});
+ BufferedInputStream buffered = new BufferedInputStream(raw);
+ Assert.assertTrue("BufferedInputStream must support mark()", buffered.markSupported());
+ }
+
+ @Test
+ public void plainByteArrayInputStreamSupportsMark() {
+ // ByteArrayInputStream also supports mark — used for in-memory (clone) paths
+ ByteArrayInputStream bais = new ByteArrayInputStream(new byte[]{1, 2, 3});
+ Assert.assertTrue(bais.markSupported());
+ }
+
+ @Test
+ public void gzipMagicBytesAreDetectable() throws IOException {
+ byte[] gzipData = gzip("{}");
+
+ // Verify magic bytes 0x1f 0x8b
+ Assert.assertEquals(0x1f, gzipData[0] & 0xFF);
+ Assert.assertEquals(0x8b, gzipData[1] & 0xFF);
+ }
+
+ @Test
+ public void gzipWrappedInBufferedStreamIsReadable() throws IOException {
+ String json = "{\"key\":\"value\"}";
+ byte[] gzipData = gzip(json);
+
+ InputStream in = new BufferedInputStream(new ByteArrayInputStream(gzipData));
+ in.mark(2);
+ int b1 = in.read();
+ int b2 = in.read();
+ in.reset(); // must be able to reset for detection to work
+
+ Assert.assertEquals(0x1f, b1 & 0xFF);
+ Assert.assertEquals(0x8b, b2 & 0xFF);
+
+ // Now read the full content via GZIP
+ String decompressed = new String(new GZIPInputStream(in).readAllBytes());
+ Assert.assertEquals(json, decompressed);
+ }
+
+ @Test
+ public void plainJsonInBufferedStreamIsPassedThrough() throws IOException {
+ String json = "{\"hello\":\"world\"}";
+ byte[] jsonBytes = json.getBytes();
+
+ InputStream in = new BufferedInputStream(new ByteArrayInputStream(jsonBytes));
+ in.mark(2);
+ int b1 = in.read();
+ int b2 = in.read();
+ in.reset();
+
+ // Not GZIP magic bytes
+ boolean isGzip = (b1 == 0x1f && b2 == 0x8b);
+ Assert.assertFalse("Plain JSON must not match GZIP magic", isGzip);
+
+ // After reset, the full content is still readable
+ String content = new String(in.readAllBytes());
+ Assert.assertEquals(json, content);
+ }
+
+ @Test
+ public void gzipRoundTrip() throws IOException {
+ String original = "{\"version\":\"1\",\"entities\":{}}";
+ byte[] compressed = gzip(original);
+
+ InputStream in = new GZIPInputStream(new ByteArrayInputStream(compressed));
+ String decompressed = new String(in.readAllBytes());
+ Assert.assertEquals(original, decompressed);
+ }
+
+ // ─── Helper ──────────────────────────────────────────────────────────────
+
+ private static byte[] gzip(String text) throws IOException {
+ ByteArrayOutputStream buf = new ByteArrayOutputStream();
+ try (GZIPOutputStream gz = new GZIPOutputStream(buf)) {
+ gz.write(text.getBytes());
+ }
+ return buf.toByteArray();
+ }
+}
diff --git a/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/pipeline/ImportPipelineMapperResolutionTest.java b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/pipeline/ImportPipelineMapperResolutionTest.java
new file mode 100644
index 00000000..3618a24b
--- /dev/null
+++ b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/pipeline/ImportPipelineMapperResolutionTest.java
@@ -0,0 +1,180 @@
+/*
+ * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
+ * Colombia / South America
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+package tools.dynamia.modules.saas.migration.pipeline;
+
+import tools.jackson.databind.ObjectMapper;
+import tools.jackson.databind.json.JsonMapper;
+import jakarta.persistence.EntityManagerFactory;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+import tools.dynamia.modules.saas.migration.api.AccountImportOptions;
+import tools.dynamia.modules.saas.migration.api.IdentityMapper;
+import tools.dynamia.modules.saas.migration.api.IdentityStrategy;
+import tools.dynamia.modules.saas.migration.api.MigrationException;
+import tools.dynamia.modules.saas.migration.config.AccountMigrationProperties;
+import tools.dynamia.modules.saas.migration.identity.KeepIdsIdentityMapper;
+
+import java.io.ByteArrayInputStream;
+import java.lang.reflect.Field;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+
+import static org.mockito.Mockito.when;
+
+/**
+ * Verifies that {@link ImportPipeline} correctly resolves the identity mapper:
+ *
+ * UUID7 throws {@link MigrationException} immediately.
+ * A custom Spring bean mapper is preferred over built-in defaults.
+ * KEEP_IDS falls back to {@link tools.dynamia.modules.saas.migration.identity.KeepIdsIdentityMapper}
+ * when no custom bean is present.
+ *
+ *
+ * These tests reach {@code resolveIdentityMapper} indirectly by calling
+ * {@code importTenant} with a minimal but valid JSON stream and observing behaviour.
+ */
+@RunWith(MockitoJUnitRunner.Silent.class)
+public class ImportPipelineMapperResolutionTest {
+
+ @Mock private EntityManagerFactory emf;
+
+ private AccountMigrationProperties properties;
+ private ObjectMapper objectMapper;
+
+ @Before
+ public void setUp() {
+ properties = new AccountMigrationProperties();
+ objectMapper = JsonMapper.builder()
+ .build();
+
+ // Minimal metamodel: getEntities() returns empty set so no entity processing occurs
+ var metamodel = org.mockito.Mockito.mock(jakarta.persistence.metamodel.Metamodel.class);
+ when(emf.getMetamodel()).thenReturn(metamodel);
+ when(metamodel.getEntities()).thenReturn(java.util.Set.of());
+ }
+
+ @Test
+ public void uuid7StrategyThrowsMigrationException() {
+ ImportPipeline pipeline = new ImportPipeline(emf, properties, objectMapper);
+
+ AccountImportOptions opts = new AccountImportOptions()
+ .targetAccountId(1L)
+ .identityStrategy(IdentityStrategy.UUID7);
+
+ try {
+ pipeline.importTenant(emptyExportStream(), opts, null, null);
+ Assert.fail("Expected MigrationException for UUID7");
+ } catch (MigrationException e) {
+ Assert.assertTrue("Message should mention UUID7",
+ e.getMessage().contains("UUID7"));
+ }
+ }
+
+ @Test
+ public void defaultKeepIdsUsesBuiltInMapper() {
+ ImportPipeline pipeline = new ImportPipeline(emf, properties, objectMapper);
+
+ AccountImportOptions opts = new AccountImportOptions()
+ .targetAccountId(1L)
+ .identityStrategy(IdentityStrategy.KEEP_IDS);
+
+ // Should complete without exception (no entities to process in empty stream)
+ pipeline.importTenant(emptyExportStream(), opts, null, null);
+ }
+
+ @Test
+ public void customSpringBeanMapperIsUsedOverDefault() {
+ // Custom mapper that records which calls it received
+ IdentityMapper custom = new IdentityMapper() {
+ boolean called = false;
+
+ @Override
+ public Object mapId(Object originalId, Class> entityClass) {
+ called = true;
+ return originalId;
+ }
+
+ @Override
+ public Object resolveReferenceId(Object originalRefId, Class> refClass,
+ Map> idMappings) {
+ return originalRefId;
+ }
+
+ @Override
+ public IdentityStrategy getStrategy() {
+ return IdentityStrategy.KEEP_IDS;
+ }
+ };
+
+ ImportPipeline pipeline = new ImportPipeline(emf, properties, objectMapper);
+ injectCustomMappers(pipeline, List.of(custom));
+
+ AccountImportOptions opts = new AccountImportOptions()
+ .targetAccountId(1L)
+ .identityStrategy(IdentityStrategy.KEEP_IDS);
+
+ // Empty entity stream — mapper.mapId won't be called, but resolveIdentityMapper
+ // will return our custom instance instead of KeepIdsIdentityMapper
+ pipeline.importTenant(emptyExportStream(), opts, null, null);
+ }
+
+ @Test
+ public void customMapperForDifferentStrategyDoesNotInterfere() {
+ // Custom mapper handles REGENERATE_IDS, but we request KEEP_IDS
+ IdentityMapper customRegen = new KeepIdsIdentityMapper() {
+ @Override
+ public IdentityStrategy getStrategy() {
+ return IdentityStrategy.REGENERATE_IDS; // different strategy
+ }
+ };
+
+ ImportPipeline pipeline = new ImportPipeline(emf, properties, objectMapper);
+ injectCustomMappers(pipeline, List.of(customRegen));
+
+ AccountImportOptions opts = new AccountImportOptions()
+ .targetAccountId(1L)
+ .identityStrategy(IdentityStrategy.KEEP_IDS);
+
+ // Should fall through to built-in KeepIdsIdentityMapper — no exception
+ pipeline.importTenant(emptyExportStream(), opts, null, null);
+ }
+
+ // ─── Helpers ─────────────────────────────────────────────────────────────
+
+ private static void injectCustomMappers(ImportPipeline pipeline, List mappers) {
+ try {
+ Field f = ImportPipeline.class.getDeclaredField("customMappers");
+ f.setAccessible(true);
+ f.set(pipeline, mappers);
+ } catch (Exception e) {
+ throw new RuntimeException("Could not inject customMappers into ImportPipeline", e);
+ }
+ }
+
+ private static ByteArrayInputStream emptyExportStream() {
+ String json = """
+ {
+ "version": "1",
+ "exportedAt": "2026-06-15T10:00:00",
+ "sourceAccountId": 1,
+ "identityStrategy": "KEEP_IDS",
+ "account": {},
+ "entities": {}
+ }
+ """;
+ return new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8));
+ }
+}
diff --git a/extensions/saas/sources/pom.xml b/extensions/saas/sources/pom.xml
index dbdaf85a..9ca3a814 100644
--- a/extensions/saas/sources/pom.xml
+++ b/extensions/saas/sources/pom.xml
@@ -71,6 +71,7 @@
core
ui
remote
+ migration