From 3c26b8b1e016662688d1b1a756c57c1bba48dbb6 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sun, 14 Jun 2026 23:22:04 -0500 Subject: [PATCH 1/7] Implement tenant migration API with core classes and interfaces --- .../modules/saas/api/AccountExportIgnore.java | 52 +++ .../modules/saas/api/ExportIgnore.java | 57 +++ .../saas/sources/migration/ARCHITECTURE.md | 334 ++++++++++++++ extensions/saas/sources/migration/README.md | 202 +++++++++ extensions/saas/sources/migration/pom.xml | 138 ++++++ .../saas/migration/api/CancellationToken.java | 67 +++ .../saas/migration/api/IdentityMapper.java | 52 +++ .../saas/migration/api/IdentityStrategy.java | 45 ++ .../migration/api/MigrationException.java | 32 ++ .../saas/migration/api/MigrationProgress.java | 37 ++ .../api/MigrationProgressListener.java | 31 ++ .../migration/api/TenantCloneOptions.java | 106 +++++ .../migration/api/TenantExportOptions.java | 97 ++++ .../migration/api/TenantImportOptions.java | 95 ++++ .../migration/api/TenantMobilityJobDto.java | 47 ++ .../api/TenantMobilityJobService.java | 113 +++++ .../migration/api/TenantMobilityService.java | 75 +++ .../config/TenantMigrationConfig.java | 89 ++++ .../config/TenantMigrationProperties.java | 102 +++++ .../controllers/TenantMobilityController.java | 250 ++++++++++ .../discovery/AccountEntityDiscovery.java | 91 ++++ .../migration/domain/TenantJobStatus.java | 35 ++ .../saas/migration/domain/TenantJobType.java | 41 ++ .../migration/domain/TenantMobilityJob.java | 242 ++++++++++ .../graph/EntityDependencyGraph.java | 144 ++++++ .../identity/KeepIdsIdentityMapper.java | 47 ++ .../identity/RegenerateIdsIdentityMapper.java | 62 +++ .../migration/pipeline/ExportConstants.java | 52 +++ .../migration/pipeline/ExportPipeline.java | 337 ++++++++++++++ .../migration/pipeline/ImportPipeline.java | 429 ++++++++++++++++++ .../TenantMobilityJobServiceImpl.java | 359 +++++++++++++++ .../services/TenantMobilityServiceImpl.java | 118 +++++ .../saas/migration/workers/CloneWorker.java | 73 +++ .../saas/migration/workers/ExportWorker.java | 79 ++++ .../saas/migration/workers/ImportWorker.java | 75 +++ 35 files changed, 4205 insertions(+) create mode 100644 extensions/saas/sources/api/src/main/java/tools/dynamia/modules/saas/api/AccountExportIgnore.java create mode 100644 extensions/saas/sources/api/src/main/java/tools/dynamia/modules/saas/api/ExportIgnore.java create mode 100644 extensions/saas/sources/migration/ARCHITECTURE.md create mode 100644 extensions/saas/sources/migration/README.md create mode 100644 extensions/saas/sources/migration/pom.xml create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/CancellationToken.java create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/IdentityMapper.java create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/IdentityStrategy.java create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/MigrationException.java create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/MigrationProgress.java create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/MigrationProgressListener.java create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantCloneOptions.java create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantExportOptions.java create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantImportOptions.java create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantMobilityJobDto.java create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantMobilityJobService.java create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantMobilityService.java create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/TenantMigrationConfig.java create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/TenantMigrationProperties.java create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/controllers/TenantMobilityController.java create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/discovery/AccountEntityDiscovery.java create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/TenantJobStatus.java create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/TenantJobType.java create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/TenantMobilityJob.java create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/graph/EntityDependencyGraph.java create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/identity/KeepIdsIdentityMapper.java create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/identity/RegenerateIdsIdentityMapper.java create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/pipeline/ExportConstants.java create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/pipeline/ExportPipeline.java create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/pipeline/ImportPipeline.java create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/TenantMobilityJobServiceImpl.java create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/TenantMobilityServiceImpl.java create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/workers/CloneWorker.java create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/workers/ExportWorker.java create mode 100644 extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/workers/ImportWorker.java 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..1973632c --- /dev/null +++ b/extensions/saas/sources/migration/ARCHITECTURE.md @@ -0,0 +1,334 @@ +# Tenant Mobility Module — Architecture + +## 1. Overview + +The Tenant Mobility 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 + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ TenantMobilityController (REST) │ +│ POST /export POST /import POST /clone GET /status GET /download │ +└─────────────────────────────┬────────────────────────────────────────┘ + │ delegates to +┌─────────────────────────────▼────────────────────────────────────────┐ +│ TenantMobilityJobService │ +│ createJob() · cancelJob() · getJob() · listJobs() │ +│ Persists TenantMobilityJob entity in DB │ +└──────┬───────────────────────────────────────────┬───────────────────┘ + │ launches via │ saves progress via + │ SchedulerUtil.runWithResult(worker) │ CrudService.update() + ▼ │ +┌─────────────────────────┐ │ +│ Workers (VirtualThread) │ │ +│ ┌──────────────────────┴─┐ │ +│ │ ExportWorker │ │ +│ │ ImportWorker │◄────────────────────┘ +│ │ CloneWorker │ +│ └────────────┬───────────┘ +│ │ calls +│ ┌────────────▼───────────┐ +│ │ TenantMobilityService │ +│ │ (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} + │ + ▼ +TenantMobilityJobService.createExportJob(accountId, options) + │ + ├── 1. Persist TenantMobilityJob{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(); +} +``` + +### Built-in Implementations + +| Class | Strategy | Use Case | +|-------|----------|---------- | +| `KeepIdsIdentityMapper` | `KEEP_IDS` | Cross-env restore to empty DB | +| `RegenerateIdsIdentityMapper` | `REGENERATE_IDS` | Clone within same DB | + +--- + +## 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 + +[![Maven Central](https://img.shields.io/maven-central/v/tools.dynamia.modules/tools.dynamia.modules.saas.migration)](https://search.maven.org/search?q=tools.dynamia.modules.saas.migration) +![Java Version Required](https://img.shields.io/badge/java-25-blue) + +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..5bd0799f --- /dev/null +++ b/extensions/saas/sources/migration/pom.xml @@ -0,0 +1,138 @@ + + + + + 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 + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + + + jakarta.servlet + jakarta.servlet-api + provided + + + jakarta.annotation + jakarta.annotation-api + provided + + + + + junit + junit + ${junit.version} + test + + + + + + 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/api/TenantCloneOptions.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantCloneOptions.java new file mode 100644 index 00000000..243c841f --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantCloneOptions.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 TenantCloneOptions { + + /** 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 TenantCloneOptions source(Long sourceAccountId) { + this.sourceAccountId = sourceAccountId; + return this; + } + + public TenantCloneOptions target(Long targetAccountId) { + this.targetAccountId = targetAccountId; + return this; + } + + public TenantCloneOptions identityStrategy(IdentityStrategy identityStrategy) { + this.identityStrategy = identityStrategy; + return this; + } + + public TenantCloneOptions 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/TenantExportOptions.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantExportOptions.java new file mode 100644 index 00000000..e2a6a753 --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantExportOptions.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 TenantExportOptions { + + /** 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 TenantExportOptions() { + } + + public TenantExportOptions(IdentityStrategy identityStrategy) { + this.identityStrategy = identityStrategy; + } + + // ─── Fluent builder ──────────────────────────────────────────────────────── + + public TenantExportOptions chunkSize(int chunkSize) { + this.chunkSize = chunkSize; + return this; + } + + public TenantExportOptions compressionEnabled(boolean compressionEnabled) { + this.compressionEnabled = compressionEnabled; + return this; + } + + public TenantExportOptions identityStrategy(IdentityStrategy identityStrategy) { + this.identityStrategy = identityStrategy; + return this; + } + + public TenantExportOptions 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/TenantImportOptions.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantImportOptions.java new file mode 100644 index 00000000..9de7afc8 --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantImportOptions.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 TenantImportOptions { + + /** + * 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 TenantImportOptions targetAccountId(Long targetAccountId) { + this.targetAccountId = targetAccountId; + return this; + } + + public TenantImportOptions identityStrategy(IdentityStrategy identityStrategy) { + this.identityStrategy = identityStrategy; + return this; + } + + public TenantImportOptions chunkSize(int chunkSize) { + this.chunkSize = chunkSize; + return this; + } + + public TenantImportOptions 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/TenantMobilityJobDto.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantMobilityJobDto.java new file mode 100644 index 00000000..a79b533f --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantMobilityJobDto.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.api; + +import tools.dynamia.modules.saas.migration.domain.TenantJobStatus; +import tools.dynamia.modules.saas.migration.domain.TenantJobType; + +import java.time.LocalDateTime; + +/** + * Read-only DTO representing the state of a {@link tools.dynamia.modules.saas.migration.domain.TenantMobilityJob}. + * Returned by REST endpoints. + * + * @author Mario Serrano Leones + */ +public record TenantMobilityJobDto( + Long id, + String uuid, + Long accountId, + Long targetAccountId, + TenantJobType jobType, + TenantJobStatus 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 == TenantJobStatus.COMPLETED + || status == TenantJobStatus.FAILED + || status == TenantJobStatus.CANCELLED; + } +} + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantMobilityJobService.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantMobilityJobService.java new file mode 100644 index 00000000..26b81008 --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantMobilityJobService.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.TenantMobilityJob; + +import java.util.List; + +/** + * Async job management service for tenant mobility operations. + * + *

Each method creates a {@link TenantMobilityJob} 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 TenantMobilityJobService { + + /** + * 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 + */ + TenantMobilityJobDto createExportJob(Long accountId, TenantExportOptions 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 + */ + TenantMobilityJobDto createImportJob(MultipartFile file, TenantImportOptions options); + + /** + * Starts an async clone job (source tenant → target tenant, same system). + * + * @param options clone configuration + * @return the newly created (PENDING) job + */ + TenantMobilityJobDto createCloneJob(TenantCloneOptions 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 + */ + TenantMobilityJobDto 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 + */ + TenantMobilityJobDto 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 + */ + TenantMobilityJobDto getJob(String jobUuid); + + /** + * Returns the raw {@link TenantMobilityJob} entity for internal use (e.g. file download). + * + * @param jobUuid UUID of the job + * @return entity or {@code null} + */ + TenantMobilityJob 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/TenantMobilityService.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantMobilityService.java new file mode 100644 index 00000000..cdff0b8a --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantMobilityService.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 TenantMobilityJobService}. + * + *

{@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 TenantMobilityService { + + /** + * 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, + TenantExportOptions 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, + TenantImportOptions 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(TenantCloneOptions options, + MigrationProgressListener listener, + CancellationToken token); +} + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/TenantMigrationConfig.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/TenantMigrationConfig.java new file mode 100644 index 00000000..1446da52 --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/TenantMigrationConfig.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 com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +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: + *

+ * + * @author Mario Serrano Leones + */ +@Configuration +@EnableConfigurationProperties(TenantMigrationProperties.class) +public class TenantMigrationConfig { + + private final TenantMigrationProperties properties; + + public TenantMigrationConfig(TenantMigrationProperties properties) { + this.properties = properties; + initOutputDirectory(); + } + + /** + * Dedicated Jackson {@link ObjectMapper} for the migration pipelines. + * + *

Configured to: + *

+ * + *

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 new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .setSerializationInclusion(JsonInclude.Include.NON_NULL); + } + + 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/TenantMigrationProperties.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/TenantMigrationProperties.java new file mode 100644 index 00000000..2052a58f --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/TenantMigrationProperties.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 TenantMigrationProperties { + + /** 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/TenantMobilityController.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/controllers/TenantMobilityController.java new file mode 100644 index 00000000..ec5d5204 --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/controllers/TenantMobilityController.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.TenantCloneOptions; +import tools.dynamia.modules.saas.migration.api.TenantExportOptions; +import tools.dynamia.modules.saas.migration.api.TenantImportOptions; +import tools.dynamia.modules.saas.migration.api.TenantMobilityJobDto; +import tools.dynamia.modules.saas.migration.api.TenantMobilityJobService; +import tools.dynamia.modules.saas.migration.domain.TenantMobilityJob; + +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 TenantMobilityController { + + private final TenantMobilityJobService jobService; + + public TenantMobilityController(TenantMobilityJobService 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) TenantExportOptions options) { + + TenantExportOptions opts = options != null ? options : new TenantExportOptions(); + TenantMobilityJobDto 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) { + + TenantImportOptions options = new TenantImportOptions() + .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(); + } + } + + TenantMobilityJobDto 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 TenantCloneOptions options) { + + if (options.getSourceAccountId() == null || options.getTargetAccountId() == null) { + return ResponseEntity.badRequest().build(); + } + TenantMobilityJobDto 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) { + TenantMobilityJobDto 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) { + + TenantMobilityJobDto 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) { + TenantMobilityJobDto 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) { + TenantMobilityJobDto 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) { + TenantMobilityJob 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

+ *
    + *
  1. Retrieves all managed entity types from {@link EntityManagerFactory#getMetamodel()}.
  2. + *
  3. Filters to classes that implement {@link AccountAware}.
  4. + *
  5. Excludes classes annotated with {@link AccountExportIgnore}.
  6. + *
  7. Always includes {@link Account} (the tenant root), regardless of the above filters.
  8. + *
+ * + * @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/TenantJobStatus.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/TenantJobStatus.java new file mode 100644 index 00000000..c7f08490 --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/TenantJobStatus.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 TenantMobilityJob}. + * + * @author Mario Serrano Leones + */ +public enum TenantJobStatus { + + /** 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 TenantMobilityJob#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/TenantJobType.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/TenantJobType.java new file mode 100644 index 00000000..6dc9b2a5 --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/TenantJobType.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 TenantMobilityJob}. + * + * @author Mario Serrano Leones + */ +public enum TenantJobType { + + /** 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/TenantMobilityJob.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/TenantMobilityJob.java new file mode 100644 index 00000000..83b8562b --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/TenantMobilityJob.java @@ -0,0 +1,242 @@ +/* + * 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 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 TenantMobilityJob 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 TenantJobType jobType; + + @Enumerated(EnumType.STRING) + @Column(length = 30) + private TenantJobStatus status = TenantJobStatus.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 tools.dynamia.modules.saas.migration.api.TenantExportOptions} + * or {@link tools.dynamia.modules.saas.migration.api.TenantImportOptions} as JSON. */ + @Column(length = 4000) + private String optionsJson; + + // ─── Helpers ─────────────────────────────────────────────────────────────── + + /** Mark the job as started. */ + public void markRunning() { + this.status = TenantJobStatus.RUNNING; + this.startedAt = LocalDateTime.now(); + } + + /** Mark the job as successfully completed. */ + public void markCompleted() { + this.status = TenantJobStatus.COMPLETED; + this.finishedAt = LocalDateTime.now(); + this.progress = 100; + } + + /** Mark the job as failed with an error message. */ + public void markFailed(String errorMessage) { + this.status = TenantJobStatus.FAILED; + this.finishedAt = LocalDateTime.now(); + this.errorMessage = errorMessage; + } + + /** Mark the job as cancelled. */ + public void markCancelled(String reason) { + this.status = TenantJobStatus.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 == TenantJobStatus.COMPLETED + || status == TenantJobStatus.FAILED + || status == TenantJobStatus.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 TenantJobType getJobType() { + return jobType; + } + + public void setJobType(TenantJobType jobType) { + this.jobType = jobType; + } + + public TenantJobStatus getStatus() { + return status; + } + + public void setStatus(TenantJobStatus 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..1b12bdc0 --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/pipeline/ExportPipeline.java @@ -0,0 +1,337 @@ +/* + * 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 com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.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.TenantExportOptions; +import tools.dynamia.modules.saas.migration.config.TenantMigrationProperties; +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 TenantMigrationProperties properties; + private final ObjectMapper objectMapper; + + public ExportPipeline(EntityManagerFactory emf, + CrudService crudService, + AccountEntityDiscovery discovery, + EntityDependencyGraph dependencyGraph, + TenantMigrationProperties 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, + TenantExportOptions 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.getFactory().createGenerator(target)) { + + gen.writeStartObject(); + gen.writeStringField(ExportConstants.FIELD_VERSION, ExportConstants.FORMAT_VERSION); + gen.writeStringField(ExportConstants.FIELD_EXPORTED_AT, LocalDateTime.now().toString()); + gen.writeNumberField(ExportConstants.FIELD_SOURCE_ACCOUNT_ID, accountId); + gen.writeStringField(ExportConstants.FIELD_IDENTITY_STRATEGY, + options.getIdentityStrategy().name()); + + // Serialize AccountDTO as the tenant descriptor + gen.writeFieldName(ExportConstants.FIELD_ACCOUNT); + objectMapper.writeValue(gen, account.toDTO()); + + // Entity section + gen.writeObjectFieldStart(ExportConstants.FIELD_ENTITIES); + + 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, + TenantExportOptions options, CancellationToken token) + throws IOException { + + long processed = 0; + int chunkSize = resolveChunkSize(options); + + gen.writeArrayFieldStart(entityClass.getName()); + + 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.writeFieldName("id"); + gen.writeObject(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.writeFieldName(name + ExportConstants.REF_ID_SUFFIX); + gen.writeObject(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.writeFieldName(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(TenantExportOptions options) { + int size = options.getChunkSize(); + return size > 0 ? size : properties.getChunkSize(); + } + + /** Count total exportable records across all entity types for progress reporting. */ + private List> buildSortedList(Long accountId, List> entities) { + List> nonEmpty = new ArrayList<>(); + for (Class ec : entities) { + if (Account.class.equals(ec)) continue; + try { + if (crudService.count(ec, QueryParameters.with("accountId", accountId)) > 0) { + nonEmpty.add(ec); + } + } catch (Exception ignored) { + nonEmpty.add(ec); + } + } + return nonEmpty; + } +} + + + 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..dd3a58d0 --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/pipeline/ImportPipeline.java @@ -0,0 +1,429 @@ +/* + * 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 com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.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.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.TenantImportOptions; +import tools.dynamia.modules.saas.migration.config.TenantMigrationProperties; +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: + *

    + *
  1. Read JSON object as a {@link JsonNode}.
  2. + *
  3. Instantiate the entity class via its no-arg constructor.
  4. + *
  5. Set primitive / embedded fields from the JSON node.
  6. + *
  7. Resolve {@code _ref_id} references via the running {@code idMappings} + * table using the configured {@link IdentityMapper}.
  8. + *
  9. Set {@code accountId} to the target account.
  10. + *
  11. Persist the entity; record original→new ID mapping.
  12. + *
+ * + * @author Mario Serrano Leones + */ +@Service +public class ImportPipeline { + + private static final Logger log = LoggerFactory.getLogger(ImportPipeline.class); + + @PersistenceContext + private EntityManager em; + + private final EntityManagerFactory emf; + private final TenantMigrationProperties properties; + private final ObjectMapper objectMapper; + + public ImportPipeline(EntityManagerFactory emf, + TenantMigrationProperties 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, + TenantImportOptions 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.getFactory().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, + TenantImportOptions 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, + TenantImportOptions 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, + TenantImportOptions 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(TenantImportOptions options) { + return switch (options.getIdentityStrategy()) { + 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/TenantMobilityJobServiceImpl.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/TenantMobilityJobServiceImpl.java new file mode 100644 index 00000000..8ce816c4 --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/TenantMobilityJobServiceImpl.java @@ -0,0 +1,359 @@ +/* + * 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 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.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.TenantCloneOptions; +import tools.dynamia.modules.saas.migration.api.TenantExportOptions; +import tools.dynamia.modules.saas.migration.api.TenantImportOptions; +import tools.dynamia.modules.saas.migration.api.TenantMobilityJobDto; +import tools.dynamia.modules.saas.migration.api.TenantMobilityJobService; +import tools.dynamia.modules.saas.migration.api.TenantMobilityService; +import tools.dynamia.modules.saas.migration.config.TenantMigrationProperties; +import tools.dynamia.modules.saas.migration.domain.TenantJobStatus; +import tools.dynamia.modules.saas.migration.domain.TenantJobType; +import tools.dynamia.modules.saas.migration.domain.TenantMobilityJob; +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.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +/** + * Default implementation of {@link TenantMobilityJobService}. + * + *

Responsibilities: + *

    + *
  1. Persist {@link TenantMobilityJob} records in the database.
  2. + *
  3. Launch worker tasks via {@link SchedulerUtil#runWithResult(tools.dynamia.integration.scheduling.TaskWithResult)} + * on virtual threads.
  4. + *
  5. Update job status, progress and result path as the worker executes.
  6. + *
  7. Maintain an in-memory {@link CancellationToken} registry so running jobs can be cancelled.
  8. + *
+ * + * @author Mario Serrano Leones + */ +@Service +public class TenantMobilityJobServiceImpl implements TenantMobilityJobService { + + private static final Logger log = LoggerFactory.getLogger(TenantMobilityJobServiceImpl.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 TenantMobilityService mobilityService; + private final TenantMigrationProperties properties; + + public TenantMobilityJobServiceImpl(CrudService crudService, + TenantMobilityService mobilityService, + TenantMigrationProperties properties) { + this.crudService = crudService; + this.mobilityService = mobilityService; + this.properties = properties; + } + + // ───────────────────────────────────────────────────────────────────────── + // Job creation + // ───────────────────────────────────────────────────────────────────────── + + @Override + public TenantMobilityJobDto createExportJob(Long accountId, TenantExportOptions options) { + TenantMobilityJob job = createAndSaveJob(accountId, null, TenantJobType.EXPORT); + launchExportJob(job, accountId, options); + return toDto(job); + } + + @Override + public TenantMobilityJobDto createBackupJob(Long accountId) { + TenantExportOptions options = new TenantExportOptions() + .compressionEnabled(properties.isCompressionEnabled()) + .label("backup"); + TenantMobilityJob job = createAndSaveJob(accountId, null, TenantJobType.BACKUP); + launchExportJob(job, accountId, options); + return toDto(job); + } + + @Override + public TenantMobilityJobDto createImportJob(MultipartFile file, TenantImportOptions options) { + Path savedFile = saveUploadedFile(file, "import"); + TenantMobilityJob job = createAndSaveJob(options.getTargetAccountId(), null, TenantJobType.IMPORT); + launchImportJob(job, savedFile, options); + return toDto(job); + } + + @Override + public TenantMobilityJobDto createRestoreJob(Long accountId, MultipartFile file) { + Path savedFile = saveUploadedFile(file, "restore"); + TenantImportOptions options = new TenantImportOptions() + .targetAccountId(accountId) + .identityStrategy(IdentityStrategy.KEEP_IDS); + TenantMobilityJob job = createAndSaveJob(accountId, null, TenantJobType.RESTORE); + launchImportJob(job, savedFile, options); + return toDto(job); + } + + @Override + public TenantMobilityJobDto createCloneJob(TenantCloneOptions options) { + TenantMobilityJob job = createAndSaveJob( + options.getSourceAccountId(), options.getTargetAccountId(), TenantJobType.CLONE); + launchCloneJob(job, options); + return toDto(job); + } + + // ───────────────────────────────────────────────────────────────────────── + // Job query + // ───────────────────────────────────────────────────────────────────────── + + @Override + public TenantMobilityJobDto getJob(String jobUuid) { + TenantMobilityJob job = findByUuid(jobUuid); + return job != null ? toDto(job) : null; + } + + @Override + public TenantMobilityJob 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(TenantMobilityJob.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 + TenantMobilityJob job = findByUuid(jobUuid); + if (job != null && !job.isFinished()) { + crudService.executeWithinTransaction(() -> { + TenantMobilityJob j = crudService.find(TenantMobilityJob.class, job.getId()); + if (j != null && !j.isFinished()) { + j.markCancelled("Cancellation requested"); + crudService.update(j); + } + }); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Worker launchers + // ───────────────────────────────────────────────────────────────────────── + + private void launchExportJob(TenantMobilityJob job, Long accountId, TenantExportOptions 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); + + SchedulerUtil.runWithResult(worker).whenComplete((result, ex) -> { + activeTokens.remove(job.getUuid()); + finalizeJob(job.getUuid(), ex, outputFile, token); + }); + } + + private void launchImportJob(TenantMobilityJob job, Path inputFile, TenantImportOptions options) { + CancellationToken token = CancellationToken.active(); + activeTokens.put(job.getUuid(), token); + + ImportWorker worker = new ImportWorker( + inputFile, options, mobilityService, + buildProgressListener(job), token); + + SchedulerUtil.runWithResult(worker).whenComplete((result, ex) -> { + activeTokens.remove(job.getUuid()); + finalizeJob(job.getUuid(), ex, null, token); + // Clean up uploaded file after import + try { + Files.deleteIfExists(inputFile); + } catch (IOException ignored) { + } + }); + } + + private void launchCloneJob(TenantMobilityJob job, TenantCloneOptions options) { + CancellationToken token = CancellationToken.active(); + activeTokens.put(job.getUuid(), token); + + CloneWorker worker = new CloneWorker( + options, mobilityService, + buildProgressListener(job), token); + + SchedulerUtil.runWithResult(worker).whenComplete((result, ex) -> { + activeTokens.remove(job.getUuid()); + finalizeJob(job.getUuid(), ex, null, token); + }); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + private TenantMobilityJob createAndSaveJob(Long accountId, Long targetAccountId, TenantJobType type) { + TenantMobilityJob job = new TenantMobilityJob(); + job.setAccountId(accountId); + job.setTargetAccountId(targetAccountId); + job.setJobType(type); + job.setStatus(TenantJobStatus.PENDING); + crudService.create(job); + log.info("[Migration/Jobs] Created job {} type={} account={}", job.getUuid(), type, accountId); + return job; + } + + private void markRunning(String jobUuid) { + crudService.executeWithinTransaction(() -> { + TenantMobilityJob job = findByUuid(jobUuid); + if (job != null) { + job.markRunning(); + crudService.update(job); + } + }); + } + + private void finalizeJob(String jobUuid, Throwable ex, Path resultFile, CancellationToken token) { + crudService.executeWithinTransaction(() -> { + TenantMobilityJob 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( + TenantMobilityJob 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(() -> { + TenantMobilityJob 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(TenantMobilityJob 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 TenantMobilityJob findByUuid(String uuid) { + return crudService.findSingle(TenantMobilityJob.class, + QueryParameters.with("uuid", uuid)); + } + + private TenantMobilityJobDto toDto(TenantMobilityJob job) { + String downloadUrl = null; + if (job.getResultPath() != null) { + downloadUrl = "/api/saas/migration/jobs/" + job.getUuid() + "/download"; + } + return new TenantMobilityJobDto( + 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/TenantMobilityServiceImpl.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/TenantMobilityServiceImpl.java new file mode 100644 index 00000000..8f69f89d --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/TenantMobilityServiceImpl.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.TenantCloneOptions; +import tools.dynamia.modules.saas.migration.api.TenantExportOptions; +import tools.dynamia.modules.saas.migration.api.TenantImportOptions; +import tools.dynamia.modules.saas.migration.api.TenantMobilityService; +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 TenantMobilityService}. + * + *

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 TenantMobilityServiceImpl implements TenantMobilityService { + + private static final Logger log = LoggerFactory.getLogger(TenantMobilityServiceImpl.class); + + private final ExportPipeline exportPipeline; + private final ImportPipeline importPipeline; + + public TenantMobilityServiceImpl(ExportPipeline exportPipeline, + ImportPipeline importPipeline) { + this.exportPipeline = exportPipeline; + this.importPipeline = importPipeline; + } + + @Override + public void exportTenant(Long accountId, + OutputStream output, + TenantExportOptions 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, + TenantImportOptions 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(TenantCloneOptions 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 ─────────────────────────────── + TenantExportOptions exportOptions = new TenantExportOptions() + .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 ──────────────────────────────────── + TenantImportOptions importOptions = new TenantImportOptions() + .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..4f933d2d --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/workers/CloneWorker.java @@ -0,0 +1,73 @@ +/* + * 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.TenantCloneOptions; +import tools.dynamia.modules.saas.migration.api.TenantMobilityService; + +/** + * Background worker that executes a tenant clone operation + * (source account → target account, same system). + * + *

Uses an in-memory {@code PipedOutputStream / PipedInputStream} bridge so + * the export and import pipelines run sequentially without writing to disk. + * For very large datasets, consider using {@link ExportWorker} followed by + * {@link ImportWorker} with a temporary file to avoid memory 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 TenantCloneOptions options; + private final TenantMobilityService mobilityService; + private final MigrationProgressListener progressListener; + private final CancellationToken cancellationToken; + + public CloneWorker(TenantCloneOptions options, + TenantMobilityService 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..119e134c --- /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.TenantExportOptions; +import tools.dynamia.modules.saas.migration.api.TenantMobilityService; + +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 TenantMobilityService#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 TenantExportOptions options; + private final TenantMobilityService mobilityService; + private final MigrationProgressListener progressListener; + private final CancellationToken cancellationToken; + + public ExportWorker(Long accountId, + Path outputFile, + TenantExportOptions options, + TenantMobilityService 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..dabe6182 --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/workers/ImportWorker.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.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.TenantImportOptions; +import tools.dynamia.modules.saas.migration.api.TenantMobilityService; + +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 TenantImportOptions options; + private final TenantMobilityService mobilityService; + private final MigrationProgressListener progressListener; + private final CancellationToken cancellationToken; + + public ImportWorker(Path inputFile, + TenantImportOptions options, + TenantMobilityService 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 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); + } + } +} + From 5cc255547e4a8592c219a6fa60f4b1a2e77476a1 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sun, 14 Jun 2026 23:22:13 -0500 Subject: [PATCH 2/7] Add migration module to the project structure --- extensions/saas/sources/pom.xml | 1 + 1 file changed, 1 insertion(+) 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 From be158e458686f3233a280b0c9521c703b1bbcb53 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sun, 14 Jun 2026 23:25:32 -0500 Subject: [PATCH 3/7] Rename tenant migration classes and update references to account migration --- ...eOptions.java => AccountCloneOptions.java} | 10 +- ...Options.java => AccountExportOptions.java} | 14 +-- ...Options.java => AccountImportOptions.java} | 10 +- ...obDto.java => AccountMigrationJobDto.java} | 19 +-- ...e.java => AccountMigrationJobService.java} | 24 ++-- ...vice.java => AccountMigrationService.java} | 10 +- ...onfig.java => AccountMigrationConfig.java} | 10 +- ...s.java => AccountMigrationProperties.java} | 2 +- ...va => AccountMigrationRestController.java} | 56 ++++----- ...ntJobStatus.java => AccountJobStatus.java} | 6 +- ...TenantJobType.java => AccountJobType.java} | 4 +- ...ilityJob.java => AccountMigrationJob.java} | 34 +++--- .../migration/pipeline/ExportPipeline.java | 14 +-- .../migration/pipeline/ImportPipeline.java | 19 ++- ...va => AccountMigrationJobServiceImpl.java} | 109 +++++++++--------- ....java => AccountMigrationServiceImpl.java} | 32 ++--- .../saas/migration/workers/CloneWorker.java | 12 +- .../saas/migration/workers/ExportWorker.java | 14 +-- .../saas/migration/workers/ImportWorker.java | 12 +- 19 files changed, 206 insertions(+), 205 deletions(-) rename extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/{TenantCloneOptions.java => AccountCloneOptions.java} (90%) rename extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/{TenantExportOptions.java => AccountExportOptions.java} (86%) rename extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/{TenantImportOptions.java => AccountImportOptions.java} (88%) rename extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/{TenantMobilityJobDto.java => AccountMigrationJobDto.java} (63%) rename extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/{TenantMobilityJobService.java => AccountMigrationJobService.java} (77%) rename extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/{TenantMobilityService.java => AccountMigrationService.java} (92%) rename extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/{TenantMigrationConfig.java => AccountMigrationConfig.java} (89%) rename extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/{TenantMigrationProperties.java => AccountMigrationProperties.java} (98%) rename extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/controllers/{TenantMobilityController.java => AccountMigrationRestController.java} (83%) rename extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/{TenantJobStatus.java => AccountJobStatus.java} (81%) rename extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/{TenantJobType.java => AccountJobType.java} (93%) rename extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/{TenantMobilityJob.java => AccountMigrationJob.java} (88%) rename extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/{TenantMobilityJobServiceImpl.java => AccountMigrationJobServiceImpl.java} (75%) rename extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/{TenantMobilityServiceImpl.java => AccountMigrationServiceImpl.java} (79%) diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantCloneOptions.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountCloneOptions.java similarity index 90% rename from extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantCloneOptions.java rename to extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountCloneOptions.java index 243c841f..a057a28b 100644 --- a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantCloneOptions.java +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountCloneOptions.java @@ -15,7 +15,7 @@ * * @author Mario Serrano Leones */ -public class TenantCloneOptions { +public class AccountCloneOptions { /** ID of the account to clone data from. Required. */ private Long sourceAccountId; @@ -41,22 +41,22 @@ public class TenantCloneOptions { // ─── Fluent builder ──────────────────────────────────────────────────────── - public TenantCloneOptions source(Long sourceAccountId) { + public AccountCloneOptions source(Long sourceAccountId) { this.sourceAccountId = sourceAccountId; return this; } - public TenantCloneOptions target(Long targetAccountId) { + public AccountCloneOptions target(Long targetAccountId) { this.targetAccountId = targetAccountId; return this; } - public TenantCloneOptions identityStrategy(IdentityStrategy identityStrategy) { + public AccountCloneOptions identityStrategy(IdentityStrategy identityStrategy) { this.identityStrategy = identityStrategy; return this; } - public TenantCloneOptions chunkSize(int chunkSize) { + public AccountCloneOptions chunkSize(int chunkSize) { this.chunkSize = chunkSize; return this; } diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantExportOptions.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountExportOptions.java similarity index 86% rename from extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantExportOptions.java rename to extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountExportOptions.java index e2a6a753..105437f0 100644 --- a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantExportOptions.java +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountExportOptions.java @@ -15,7 +15,7 @@ * * @author Mario Serrano Leones */ -public class TenantExportOptions { +public class AccountExportOptions { /** Number of records to read from DB per pagination page. Default: 500. */ private int chunkSize = 500; @@ -31,31 +31,31 @@ public class TenantExportOptions { // ─── Constructors ────────────────────────────────────────────────────────── - public TenantExportOptions() { + public AccountExportOptions() { } - public TenantExportOptions(IdentityStrategy identityStrategy) { + public AccountExportOptions(IdentityStrategy identityStrategy) { this.identityStrategy = identityStrategy; } // ─── Fluent builder ──────────────────────────────────────────────────────── - public TenantExportOptions chunkSize(int chunkSize) { + public AccountExportOptions chunkSize(int chunkSize) { this.chunkSize = chunkSize; return this; } - public TenantExportOptions compressionEnabled(boolean compressionEnabled) { + public AccountExportOptions compressionEnabled(boolean compressionEnabled) { this.compressionEnabled = compressionEnabled; return this; } - public TenantExportOptions identityStrategy(IdentityStrategy identityStrategy) { + public AccountExportOptions identityStrategy(IdentityStrategy identityStrategy) { this.identityStrategy = identityStrategy; return this; } - public TenantExportOptions label(String label) { + public AccountExportOptions label(String label) { this.label = label; return this; } diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantImportOptions.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountImportOptions.java similarity index 88% rename from extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantImportOptions.java rename to extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountImportOptions.java index 9de7afc8..3a8a1a53 100644 --- a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantImportOptions.java +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountImportOptions.java @@ -15,7 +15,7 @@ * * @author Mario Serrano Leones */ -public class TenantImportOptions { +public class AccountImportOptions { /** * Target account ID. @@ -38,22 +38,22 @@ public class TenantImportOptions { // ─── Fluent builder ──────────────────────────────────────────────────────── - public TenantImportOptions targetAccountId(Long targetAccountId) { + public AccountImportOptions targetAccountId(Long targetAccountId) { this.targetAccountId = targetAccountId; return this; } - public TenantImportOptions identityStrategy(IdentityStrategy identityStrategy) { + public AccountImportOptions identityStrategy(IdentityStrategy identityStrategy) { this.identityStrategy = identityStrategy; return this; } - public TenantImportOptions chunkSize(int chunkSize) { + public AccountImportOptions chunkSize(int chunkSize) { this.chunkSize = chunkSize; return this; } - public TenantImportOptions failOnEntityError(boolean failOnEntityError) { + public AccountImportOptions failOnEntityError(boolean failOnEntityError) { this.failOnEntityError = failOnEntityError; return this; } diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantMobilityJobDto.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationJobDto.java similarity index 63% rename from extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantMobilityJobDto.java rename to extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationJobDto.java index a79b533f..0a9a2980 100644 --- a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantMobilityJobDto.java +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationJobDto.java @@ -10,24 +10,25 @@ */ package tools.dynamia.modules.saas.migration.api; -import tools.dynamia.modules.saas.migration.domain.TenantJobStatus; -import tools.dynamia.modules.saas.migration.domain.TenantJobType; +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 tools.dynamia.modules.saas.migration.domain.TenantMobilityJob}. + * Read-only DTO representing the state of a {@link AccountMigrationJob}. * Returned by REST endpoints. * * @author Mario Serrano Leones */ -public record TenantMobilityJobDto( +public record AccountMigrationJobDto( Long id, String uuid, Long accountId, Long targetAccountId, - TenantJobType jobType, - TenantJobStatus status, + AccountJobType jobType, + AccountJobStatus status, int progress, String progressMessage, String errorMessage, @@ -39,9 +40,9 @@ public record TenantMobilityJobDto( /** Convenience: returns {@code true} when the job has reached a terminal state. */ public boolean isFinished() { - return status == TenantJobStatus.COMPLETED - || status == TenantJobStatus.FAILED - || status == TenantJobStatus.CANCELLED; + 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/TenantMobilityJobService.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationJobService.java similarity index 77% rename from extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantMobilityJobService.java rename to extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationJobService.java index 26b81008..f75c9bee 100644 --- a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantMobilityJobService.java +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationJobService.java @@ -11,14 +11,14 @@ package tools.dynamia.modules.saas.migration.api; import org.springframework.web.multipart.MultipartFile; -import tools.dynamia.modules.saas.migration.domain.TenantMobilityJob; +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 TenantMobilityJob} record, launches the operation + *

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). * @@ -30,7 +30,7 @@ * * @author Mario Serrano Leones */ -public interface TenantMobilityJobService { +public interface AccountMigrationJobService { /** * Starts an async export job for the given account. @@ -39,7 +39,7 @@ public interface TenantMobilityJobService { * @param options export configuration * @return the newly created (PENDING) job */ - TenantMobilityJobDto createExportJob(Long accountId, TenantExportOptions options); + AccountMigrationJobDto createExportJob(Long accountId, AccountExportOptions options); /** * Starts an async import job from an uploaded file. @@ -48,7 +48,7 @@ public interface TenantMobilityJobService { * @param options import configuration (target account, identity strategy, etc.) * @return the newly created (PENDING) job */ - TenantMobilityJobDto createImportJob(MultipartFile file, TenantImportOptions options); + AccountMigrationJobDto createImportJob(MultipartFile file, AccountImportOptions options); /** * Starts an async clone job (source tenant → target tenant, same system). @@ -56,7 +56,7 @@ public interface TenantMobilityJobService { * @param options clone configuration * @return the newly created (PENDING) job */ - TenantMobilityJobDto createCloneJob(TenantCloneOptions options); + AccountMigrationJobDto createCloneJob(AccountCloneOptions options); /** * Starts an async backup job (semantically equivalent to export with BACKUP type label). @@ -64,7 +64,7 @@ public interface TenantMobilityJobService { * @param accountId ID of the account to back up * @return the newly created (PENDING) job */ - TenantMobilityJobDto createBackupJob(Long accountId); + AccountMigrationJobDto createBackupJob(Long accountId); /** * Starts an async restore job from an uploaded file @@ -74,7 +74,7 @@ public interface TenantMobilityJobService { * @param file multipart upload * @return the newly created (PENDING) job */ - TenantMobilityJobDto createRestoreJob(Long accountId, MultipartFile file); + AccountMigrationJobDto createRestoreJob(Long accountId, MultipartFile file); /** * Returns the current state of the job identified by {@code jobUuid}. @@ -82,15 +82,15 @@ public interface TenantMobilityJobService { * @param jobUuid UUID of the job * @return job DTO or {@code null} if not found */ - TenantMobilityJobDto getJob(String jobUuid); + AccountMigrationJobDto getJob(String jobUuid); /** - * Returns the raw {@link TenantMobilityJob} entity for internal use (e.g. file download). + * Returns the raw {@link AccountMigrationJob} entity for internal use (e.g. file download). * * @param jobUuid UUID of the job * @return entity or {@code null} */ - TenantMobilityJob getJobEntity(String jobUuid); + AccountMigrationJob getJobEntity(String jobUuid); /** * Lists all known jobs, optionally filtered by account. @@ -98,7 +98,7 @@ public interface TenantMobilityJobService { * @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); + List listJobs(Long accountId); /** * Requests cooperative cancellation of a running job. diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantMobilityService.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationService.java similarity index 92% rename from extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantMobilityService.java rename to extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationService.java index cdff0b8a..8384e1e9 100644 --- a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/TenantMobilityService.java +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationService.java @@ -18,7 +18,7 @@ * *

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 TenantMobilityJobService}. + * {@link AccountMigrationJobService}. * *

{@code
  * // Direct usage (synchronous — blocks until complete):
@@ -30,7 +30,7 @@
  *
  * @author Mario Serrano Leones
  */
-public interface TenantMobilityService {
+public interface AccountMigrationService {
 
     /**
      * Exports all tenant data for {@code accountId} to the given {@code output} stream.
@@ -43,7 +43,7 @@ public interface TenantMobilityService {
      */
     void exportTenant(Long accountId,
                       OutputStream output,
-                      TenantExportOptions options,
+                      AccountExportOptions options,
                       MigrationProgressListener listener,
                       CancellationToken token);
 
@@ -56,7 +56,7 @@ void exportTenant(Long accountId,
      * @param token    optional cancellation token; may be {@code null}
      */
     void importTenant(InputStream input,
-                      TenantImportOptions options,
+                      AccountImportOptions options,
                       MigrationProgressListener listener,
                       CancellationToken token);
 
@@ -68,7 +68,7 @@ void importTenant(InputStream input,
      * @param listener optional progress callback; may be {@code null}
      * @param token    optional cancellation token; may be {@code null}
      */
-    void cloneTenant(TenantCloneOptions options,
+    void cloneTenant(AccountCloneOptions options,
                      MigrationProgressListener listener,
                      CancellationToken token);
 }
diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/TenantMigrationConfig.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/AccountMigrationConfig.java
similarity index 89%
rename from extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/TenantMigrationConfig.java
rename to extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/AccountMigrationConfig.java
index 1446da52..117a3f90 100644
--- a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/TenantMigrationConfig.java
+++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/AccountMigrationConfig.java
@@ -31,19 +31,19 @@
  * 

Registers: *

    *
  • A migration-specific {@link ObjectMapper} (qualified name: {@code migrationObjectMapper}).
  • - *
  • The default {@link TenantMobilityService} implementation.
  • + *
  • The default {@link tools.dynamia.modules.saas.migration.api.AccountMigrationService} implementation.
  • *
  • Ensures the output directory exists at startup.
  • *
* * @author Mario Serrano Leones */ @Configuration -@EnableConfigurationProperties(TenantMigrationProperties.class) -public class TenantMigrationConfig { +@EnableConfigurationProperties(AccountMigrationProperties.class) +public class AccountMigrationConfig { - private final TenantMigrationProperties properties; + private final AccountMigrationProperties properties; - public TenantMigrationConfig(TenantMigrationProperties properties) { + public AccountMigrationConfig(AccountMigrationProperties properties) { this.properties = properties; initOutputDirectory(); } diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/TenantMigrationProperties.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/AccountMigrationProperties.java similarity index 98% rename from extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/TenantMigrationProperties.java rename to extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/AccountMigrationProperties.java index 2052a58f..43334185 100644 --- a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/TenantMigrationProperties.java +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/AccountMigrationProperties.java @@ -29,7 +29,7 @@ * @author Mario Serrano Leones */ @ConfigurationProperties(prefix = "dynamia.saas.migration") -public class TenantMigrationProperties { +public class AccountMigrationProperties { /** Number of records read/written per pagination page. Default: 500. */ private int chunkSize = 500; diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/controllers/TenantMobilityController.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/controllers/AccountMigrationRestController.java similarity index 83% rename from extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/controllers/TenantMobilityController.java rename to extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/controllers/AccountMigrationRestController.java index ec5d5204..5c957164 100644 --- a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/controllers/TenantMobilityController.java +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/controllers/AccountMigrationRestController.java @@ -24,12 +24,12 @@ 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.TenantCloneOptions; -import tools.dynamia.modules.saas.migration.api.TenantExportOptions; -import tools.dynamia.modules.saas.migration.api.TenantImportOptions; -import tools.dynamia.modules.saas.migration.api.TenantMobilityJobDto; -import tools.dynamia.modules.saas.migration.api.TenantMobilityJobService; -import tools.dynamia.modules.saas.migration.domain.TenantMobilityJob; +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; @@ -61,11 +61,11 @@ */ @RestController @RequestMapping(value = "/api/saas/migration", produces = MediaType.APPLICATION_JSON_VALUE) -public class TenantMobilityController { +public class AccountMigrationRestController { - private final TenantMobilityJobService jobService; + private final AccountMigrationJobService jobService; - public TenantMobilityController(TenantMobilityJobService jobService) { + public AccountMigrationRestController(AccountMigrationJobService jobService) { this.jobService = jobService; } @@ -86,12 +86,12 @@ public TenantMobilityController(TenantMobilityJobService jobService) { * }
*/ @PostMapping("/jobs/export/{accountId}") - public ResponseEntity startExport( + public ResponseEntity startExport( @PathVariable Long accountId, - @RequestBody(required = false) TenantExportOptions options) { + @RequestBody(required = false) AccountExportOptions options) { - TenantExportOptions opts = options != null ? options : new TenantExportOptions(); - TenantMobilityJobDto job = jobService.createExportJob(accountId, opts); + AccountExportOptions opts = options != null ? options : new AccountExportOptions(); + AccountMigrationJobDto job = jobService.createExportJob(accountId, opts); return ResponseEntity.status(HttpStatus.ACCEPTED).body(job); } @@ -111,13 +111,13 @@ public ResponseEntity startExport( * */ @PostMapping(value = "/jobs/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ResponseEntity startImport( + public ResponseEntity startImport( @RequestParam("file") MultipartFile file, @RequestParam(required = false) Long targetAccountId, @RequestParam(required = false) String identityStrategy, @RequestParam(required = false, defaultValue = "0") int chunkSize) { - TenantImportOptions options = new TenantImportOptions() + AccountImportOptions options = new AccountImportOptions() .targetAccountId(targetAccountId) .chunkSize(chunkSize > 0 ? chunkSize : 500); @@ -130,7 +130,7 @@ public ResponseEntity startImport( } } - TenantMobilityJobDto job = jobService.createImportJob(file, options); + AccountMigrationJobDto job = jobService.createImportJob(file, options); return ResponseEntity.status(HttpStatus.ACCEPTED).body(job); } @@ -152,13 +152,13 @@ public ResponseEntity startImport( * } */ @PostMapping("/jobs/clone") - public ResponseEntity startClone( - @RequestBody TenantCloneOptions options) { + public ResponseEntity startClone( + @RequestBody AccountCloneOptions options) { if (options.getSourceAccountId() == null || options.getTargetAccountId() == null) { return ResponseEntity.badRequest().build(); } - TenantMobilityJobDto job = jobService.createCloneJob(options); + AccountMigrationJobDto job = jobService.createCloneJob(options); return ResponseEntity.status(HttpStatus.ACCEPTED).body(job); } @@ -168,18 +168,18 @@ public ResponseEntity startClone( /** Start a backup job (export with BACKUP type label and compression). */ @PostMapping("/jobs/backup/{accountId}") - public ResponseEntity startBackup(@PathVariable Long accountId) { - TenantMobilityJobDto job = jobService.createBackupJob(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( + public ResponseEntity startRestore( @PathVariable Long accountId, @RequestParam("file") MultipartFile file) { - TenantMobilityJobDto job = jobService.createRestoreJob(accountId, file); + AccountMigrationJobDto job = jobService.createRestoreJob(accountId, file); return ResponseEntity.status(HttpStatus.ACCEPTED).body(job); } @@ -191,15 +191,15 @@ public ResponseEntity startRestore( * List all jobs. Pass {@code ?accountId=X} to filter by account. */ @GetMapping("/jobs") - public ResponseEntity> listJobs( + 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) { - TenantMobilityJobDto job = jobService.getJob(jobId); + public ResponseEntity getJob(@PathVariable String jobId) { + AccountMigrationJobDto job = jobService.getJob(jobId); if (job == null) return ResponseEntity.notFound().build(); return ResponseEntity.ok(job); } @@ -207,7 +207,7 @@ public ResponseEntity getJob(@PathVariable String jobId) { /** Request cancellation of a running job. Idempotent. */ @PostMapping("/jobs/{jobId}/cancel") public ResponseEntity> cancelJob(@PathVariable String jobId) { - TenantMobilityJobDto job = jobService.getJob(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)); @@ -223,7 +223,7 @@ public ResponseEntity> cancelJob(@PathVariable String jobId) */ @GetMapping("/jobs/{jobId}/download") public ResponseEntity downloadResult(@PathVariable String jobId) { - TenantMobilityJob job = jobService.getJobEntity(jobId); + AccountMigrationJob job = jobService.getJobEntity(jobId); if (job == null || job.getResultPath() == null) { return ResponseEntity.notFound().build(); } diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/TenantJobStatus.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountJobStatus.java similarity index 81% rename from extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/TenantJobStatus.java rename to extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountJobStatus.java index c7f08490..6688409d 100644 --- a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/TenantJobStatus.java +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountJobStatus.java @@ -11,11 +11,11 @@ package tools.dynamia.modules.saas.migration.domain; /** - * Lifecycle status of a {@link TenantMobilityJob}. + * Lifecycle status of a {@link AccountMigrationJob}. * * @author Mario Serrano Leones */ -public enum TenantJobStatus { +public enum AccountJobStatus { /** Job has been created but not started yet. */ PENDING, @@ -26,7 +26,7 @@ public enum TenantJobStatus { /** Job finished successfully. Result file is available for download. */ COMPLETED, - /** Job failed with an error. See {@link TenantMobilityJob#getErrorMessage()}. */ + /** Job failed with an error. See {@link AccountMigrationJob#getErrorMessage()}. */ FAILED, /** Job was cancelled by the user before it completed. */ diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/TenantJobType.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountJobType.java similarity index 93% rename from extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/TenantJobType.java rename to extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountJobType.java index 6dc9b2a5..7cad29af 100644 --- a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/TenantJobType.java +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountJobType.java @@ -11,11 +11,11 @@ package tools.dynamia.modules.saas.migration.domain; /** - * Type of a {@link TenantMobilityJob}. + * Type of a {@link AccountMigrationJob}. * * @author Mario Serrano Leones */ -public enum TenantJobType { +public enum AccountJobType { /** Export all tenant data to a JSON/GZIP file. */ EXPORT, diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/TenantMobilityJob.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountMigrationJob.java similarity index 88% rename from extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/TenantMobilityJob.java rename to extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountMigrationJob.java index 83b8562b..a4add282 100644 --- a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/TenantMobilityJob.java +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountMigrationJob.java @@ -17,6 +17,8 @@ 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; @@ -32,7 +34,7 @@ */ @Entity @Table(name = "saas_migration_jobs") -public class TenantMobilityJob extends SimpleEntity { +public class AccountMigrationJob extends SimpleEntity { // ─── Identity ────────────────────────────────────────────────────────────── @@ -52,11 +54,11 @@ public class TenantMobilityJob extends SimpleEntity { @Enumerated(EnumType.STRING) @Column(length = 30) - private TenantJobType jobType; + private AccountJobType jobType; @Enumerated(EnumType.STRING) @Column(length = 30) - private TenantJobStatus status = TenantJobStatus.PENDING; + private AccountJobStatus status = AccountJobStatus.PENDING; // ─── Progress ────────────────────────────────────────────────────────────── @@ -81,8 +83,8 @@ public class TenantMobilityJob extends SimpleEntity { @Column(length = 1000) private String resultPath; - /** Serialized {@link tools.dynamia.modules.saas.migration.api.TenantExportOptions} - * or {@link tools.dynamia.modules.saas.migration.api.TenantImportOptions} as JSON. */ + /** Serialized {@link AccountExportOptions} + * or {@link AccountImportOptions} as JSON. */ @Column(length = 4000) private String optionsJson; @@ -90,27 +92,27 @@ public class TenantMobilityJob extends SimpleEntity { /** Mark the job as started. */ public void markRunning() { - this.status = TenantJobStatus.RUNNING; + this.status = AccountJobStatus.RUNNING; this.startedAt = LocalDateTime.now(); } /** Mark the job as successfully completed. */ public void markCompleted() { - this.status = TenantJobStatus.COMPLETED; + 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 = TenantJobStatus.FAILED; + this.status = AccountJobStatus.FAILED; this.finishedAt = LocalDateTime.now(); this.errorMessage = errorMessage; } /** Mark the job as cancelled. */ public void markCancelled(String reason) { - this.status = TenantJobStatus.CANCELLED; + this.status = AccountJobStatus.CANCELLED; this.finishedAt = LocalDateTime.now(); this.progressMessage = reason; } @@ -123,9 +125,9 @@ public void updateProgress(int progress, String message) { /** Returns {@code true} if the job is in a terminal state. */ public boolean isFinished() { - return status == TenantJobStatus.COMPLETED - || status == TenantJobStatus.FAILED - || status == TenantJobStatus.CANCELLED; + return status == AccountJobStatus.COMPLETED + || status == AccountJobStatus.FAILED + || status == AccountJobStatus.CANCELLED; } // ─── Accessors ───────────────────────────────────────────────────────────── @@ -154,19 +156,19 @@ public void setTargetAccountId(Long targetAccountId) { this.targetAccountId = targetAccountId; } - public TenantJobType getJobType() { + public AccountJobType getJobType() { return jobType; } - public void setJobType(TenantJobType jobType) { + public void setJobType(AccountJobType jobType) { this.jobType = jobType; } - public TenantJobStatus getStatus() { + public AccountJobStatus getStatus() { return status; } - public void setStatus(TenantJobStatus status) { + public void setStatus(AccountJobStatus status) { this.status = status; } 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 index 1b12bdc0..3a3de222 100644 --- 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 @@ -30,8 +30,8 @@ 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.TenantExportOptions; -import tools.dynamia.modules.saas.migration.config.TenantMigrationProperties; +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; @@ -73,14 +73,14 @@ public class ExportPipeline { private final CrudService crudService; private final AccountEntityDiscovery discovery; private final EntityDependencyGraph dependencyGraph; - private final TenantMigrationProperties properties; + private final AccountMigrationProperties properties; private final ObjectMapper objectMapper; public ExportPipeline(EntityManagerFactory emf, CrudService crudService, AccountEntityDiscovery discovery, EntityDependencyGraph dependencyGraph, - TenantMigrationProperties properties, + AccountMigrationProperties properties, @Qualifier("migrationObjectMapper") ObjectMapper objectMapper) { this.emf = emf; this.crudService = crudService; @@ -101,7 +101,7 @@ public ExportPipeline(EntityManagerFactory emf, */ public void export(Long accountId, OutputStream output, - TenantExportOptions options, + AccountExportOptions options, MigrationProgressListener listener, CancellationToken token) { @@ -183,7 +183,7 @@ public void export(Long accountId, // ───────────────────────────────────────────────────────────────────────── private long exportEntityType(JsonGenerator gen, Class entityClass, Long accountId, - TenantExportOptions options, CancellationToken token) + AccountExportOptions options, CancellationToken token) throws IOException { long processed = 0; @@ -311,7 +311,7 @@ private Field findField(Class clazz, String fieldName) { return null; } - private int resolveChunkSize(TenantExportOptions options) { + 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 index dd3a58d0..57f341fd 100644 --- 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 @@ -29,12 +29,11 @@ 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.TenantImportOptions; -import tools.dynamia.modules.saas.migration.config.TenantMigrationProperties; +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; @@ -77,11 +76,11 @@ public class ImportPipeline { private EntityManager em; private final EntityManagerFactory emf; - private final TenantMigrationProperties properties; + private final AccountMigrationProperties properties; private final ObjectMapper objectMapper; public ImportPipeline(EntityManagerFactory emf, - TenantMigrationProperties properties, + AccountMigrationProperties properties, @Qualifier("migrationObjectMapper") ObjectMapper objectMapper) { this.emf = emf; this.properties = properties; @@ -97,7 +96,7 @@ public ImportPipeline(EntityManagerFactory emf, * @param token optional cancellation token */ public void importTenant(InputStream input, - TenantImportOptions options, + AccountImportOptions options, MigrationProgressListener listener, CancellationToken token) { IdentityMapper identityMapper = resolveIdentityMapper(options); @@ -154,7 +153,7 @@ public void importTenant(InputStream input, // ───────────────────────────────────────────────────────────────────────── private long importEntitiesSection(JsonParser parser, - TenantImportOptions options, + AccountImportOptions options, IdentityMapper identityMapper, Map> idMappings, MigrationProgressListener listener, @@ -195,7 +194,7 @@ private long importEntitiesSection(JsonParser parser, private long importEntityArray(JsonParser parser, Class entityClass, - TenantImportOptions options, + AccountImportOptions options, IdentityMapper identityMapper, Map> idMappings, MigrationProgressListener listener, @@ -233,7 +232,7 @@ private long importEntityArray(JsonParser parser, @Transactional(propagation = Propagation.REQUIRES_NEW) public int persistChunk(List chunk, Class entityClass, - TenantImportOptions options, + AccountImportOptions options, IdentityMapper identityMapper, Map> idMappings) { int count = 0; @@ -404,7 +403,7 @@ private static InputStream detectAndWrapGzip(InputStream in) throws IOException return in; } - private IdentityMapper resolveIdentityMapper(TenantImportOptions options) { + private IdentityMapper resolveIdentityMapper(AccountImportOptions options) { return switch (options.getIdentityStrategy()) { case KEEP_IDS -> new KeepIdsIdentityMapper(); default -> new RegenerateIdsIdentityMapper(); diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/TenantMobilityJobServiceImpl.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/AccountMigrationJobServiceImpl.java similarity index 75% rename from extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/TenantMobilityJobServiceImpl.java rename to extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/AccountMigrationJobServiceImpl.java index 8ce816c4..a68e3f2d 100644 --- a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/TenantMobilityJobServiceImpl.java +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/AccountMigrationJobServiceImpl.java @@ -20,16 +20,16 @@ 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.TenantCloneOptions; -import tools.dynamia.modules.saas.migration.api.TenantExportOptions; -import tools.dynamia.modules.saas.migration.api.TenantImportOptions; -import tools.dynamia.modules.saas.migration.api.TenantMobilityJobDto; -import tools.dynamia.modules.saas.migration.api.TenantMobilityJobService; -import tools.dynamia.modules.saas.migration.api.TenantMobilityService; -import tools.dynamia.modules.saas.migration.config.TenantMigrationProperties; -import tools.dynamia.modules.saas.migration.domain.TenantJobStatus; -import tools.dynamia.modules.saas.migration.domain.TenantJobType; -import tools.dynamia.modules.saas.migration.domain.TenantMobilityJob; +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; @@ -42,18 +42,17 @@ import java.nio.file.StandardCopyOption; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; -import java.util.Collections; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; /** - * Default implementation of {@link TenantMobilityJobService}. + * Default implementation of {@link AccountMigrationJobService}. * *

Responsibilities: *

    - *
  1. Persist {@link TenantMobilityJob} records in the database.
  2. + *
  3. Persist {@link AccountMigrationJob} records in the database.
  4. *
  5. Launch worker tasks via {@link SchedulerUtil#runWithResult(tools.dynamia.integration.scheduling.TaskWithResult)} * on virtual threads.
  6. *
  7. Update job status, progress and result path as the worker executes.
  8. @@ -63,21 +62,21 @@ * @author Mario Serrano Leones */ @Service -public class TenantMobilityJobServiceImpl implements TenantMobilityJobService { +public class AccountMigrationJobServiceImpl implements AccountMigrationJobService { - private static final Logger log = LoggerFactory.getLogger(TenantMobilityJobServiceImpl.class); + 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 TenantMobilityService mobilityService; - private final TenantMigrationProperties properties; + private final AccountMigrationService mobilityService; + private final AccountMigrationProperties properties; - public TenantMobilityJobServiceImpl(CrudService crudService, - TenantMobilityService mobilityService, - TenantMigrationProperties properties) { + public AccountMigrationJobServiceImpl(CrudService crudService, + AccountMigrationService mobilityService, + AccountMigrationProperties properties) { this.crudService = crudService; this.mobilityService = mobilityService; this.properties = properties; @@ -88,45 +87,45 @@ public TenantMobilityJobServiceImpl(CrudService crudService, // ───────────────────────────────────────────────────────────────────────── @Override - public TenantMobilityJobDto createExportJob(Long accountId, TenantExportOptions options) { - TenantMobilityJob job = createAndSaveJob(accountId, null, TenantJobType.EXPORT); + public AccountMigrationJobDto createExportJob(Long accountId, AccountExportOptions options) { + AccountMigrationJob job = createAndSaveJob(accountId, null, AccountJobType.EXPORT); launchExportJob(job, accountId, options); return toDto(job); } @Override - public TenantMobilityJobDto createBackupJob(Long accountId) { - TenantExportOptions options = new TenantExportOptions() + public AccountMigrationJobDto createBackupJob(Long accountId) { + AccountExportOptions options = new AccountExportOptions() .compressionEnabled(properties.isCompressionEnabled()) .label("backup"); - TenantMobilityJob job = createAndSaveJob(accountId, null, TenantJobType.BACKUP); + AccountMigrationJob job = createAndSaveJob(accountId, null, AccountJobType.BACKUP); launchExportJob(job, accountId, options); return toDto(job); } @Override - public TenantMobilityJobDto createImportJob(MultipartFile file, TenantImportOptions options) { + public AccountMigrationJobDto createImportJob(MultipartFile file, AccountImportOptions options) { Path savedFile = saveUploadedFile(file, "import"); - TenantMobilityJob job = createAndSaveJob(options.getTargetAccountId(), null, TenantJobType.IMPORT); + AccountMigrationJob job = createAndSaveJob(options.getTargetAccountId(), null, AccountJobType.IMPORT); launchImportJob(job, savedFile, options); return toDto(job); } @Override - public TenantMobilityJobDto createRestoreJob(Long accountId, MultipartFile file) { + public AccountMigrationJobDto createRestoreJob(Long accountId, MultipartFile file) { Path savedFile = saveUploadedFile(file, "restore"); - TenantImportOptions options = new TenantImportOptions() + AccountImportOptions options = new AccountImportOptions() .targetAccountId(accountId) .identityStrategy(IdentityStrategy.KEEP_IDS); - TenantMobilityJob job = createAndSaveJob(accountId, null, TenantJobType.RESTORE); + AccountMigrationJob job = createAndSaveJob(accountId, null, AccountJobType.RESTORE); launchImportJob(job, savedFile, options); return toDto(job); } @Override - public TenantMobilityJobDto createCloneJob(TenantCloneOptions options) { - TenantMobilityJob job = createAndSaveJob( - options.getSourceAccountId(), options.getTargetAccountId(), TenantJobType.CLONE); + public AccountMigrationJobDto createCloneJob(AccountCloneOptions options) { + AccountMigrationJob job = createAndSaveJob( + options.getSourceAccountId(), options.getTargetAccountId(), AccountJobType.CLONE); launchCloneJob(job, options); return toDto(job); } @@ -136,24 +135,24 @@ public TenantMobilityJobDto createCloneJob(TenantCloneOptions options) { // ───────────────────────────────────────────────────────────────────────── @Override - public TenantMobilityJobDto getJob(String jobUuid) { - TenantMobilityJob job = findByUuid(jobUuid); + public AccountMigrationJobDto getJob(String jobUuid) { + AccountMigrationJob job = findByUuid(jobUuid); return job != null ? toDto(job) : null; } @Override - public TenantMobilityJob getJobEntity(String jobUuid) { + public AccountMigrationJob getJobEntity(String jobUuid) { return findByUuid(jobUuid); } @Override - public List listJobs(Long accountId) { + public List listJobs(Long accountId) { QueryParameters qp = new QueryParameters() .orderBy("createdAt", false); if (accountId != null) { qp.add("accountId", accountId); } - return crudService.find(TenantMobilityJob.class, qp) + return crudService.find(AccountMigrationJob.class, qp) .stream() .map(this::toDto) .collect(Collectors.toList()); @@ -169,10 +168,10 @@ public void cancelJob(String jobUuid) { log.warn("[Migration/Jobs] No active token found for job {} (already finished?)", jobUuid); } // Optimistically update status in DB - TenantMobilityJob job = findByUuid(jobUuid); + AccountMigrationJob job = findByUuid(jobUuid); if (job != null && !job.isFinished()) { crudService.executeWithinTransaction(() -> { - TenantMobilityJob j = crudService.find(TenantMobilityJob.class, job.getId()); + AccountMigrationJob j = crudService.find(AccountMigrationJob.class, job.getId()); if (j != null && !j.isFinished()) { j.markCancelled("Cancellation requested"); crudService.update(j); @@ -185,7 +184,7 @@ public void cancelJob(String jobUuid) { // Worker launchers // ───────────────────────────────────────────────────────────────────────── - private void launchExportJob(TenantMobilityJob job, Long accountId, TenantExportOptions options) { + private void launchExportJob(AccountMigrationJob job, Long accountId, AccountExportOptions options) { CancellationToken token = CancellationToken.active(); activeTokens.put(job.getUuid(), token); @@ -201,7 +200,7 @@ private void launchExportJob(TenantMobilityJob job, Long accountId, TenantExport }); } - private void launchImportJob(TenantMobilityJob job, Path inputFile, TenantImportOptions options) { + private void launchImportJob(AccountMigrationJob job, Path inputFile, AccountImportOptions options) { CancellationToken token = CancellationToken.active(); activeTokens.put(job.getUuid(), token); @@ -220,7 +219,7 @@ private void launchImportJob(TenantMobilityJob job, Path inputFile, TenantImport }); } - private void launchCloneJob(TenantMobilityJob job, TenantCloneOptions options) { + private void launchCloneJob(AccountMigrationJob job, AccountCloneOptions options) { CancellationToken token = CancellationToken.active(); activeTokens.put(job.getUuid(), token); @@ -238,12 +237,12 @@ private void launchCloneJob(TenantMobilityJob job, TenantCloneOptions options) { // Helpers // ───────────────────────────────────────────────────────────────────────── - private TenantMobilityJob createAndSaveJob(Long accountId, Long targetAccountId, TenantJobType type) { - TenantMobilityJob job = new TenantMobilityJob(); + private AccountMigrationJob createAndSaveJob(Long accountId, Long targetAccountId, AccountJobType type) { + AccountMigrationJob job = new AccountMigrationJob(); job.setAccountId(accountId); job.setTargetAccountId(targetAccountId); job.setJobType(type); - job.setStatus(TenantJobStatus.PENDING); + job.setStatus(AccountJobStatus.PENDING); crudService.create(job); log.info("[Migration/Jobs] Created job {} type={} account={}", job.getUuid(), type, accountId); return job; @@ -251,7 +250,7 @@ private TenantMobilityJob createAndSaveJob(Long accountId, Long targetAccountId, private void markRunning(String jobUuid) { crudService.executeWithinTransaction(() -> { - TenantMobilityJob job = findByUuid(jobUuid); + AccountMigrationJob job = findByUuid(jobUuid); if (job != null) { job.markRunning(); crudService.update(job); @@ -261,7 +260,7 @@ private void markRunning(String jobUuid) { private void finalizeJob(String jobUuid, Throwable ex, Path resultFile, CancellationToken token) { crudService.executeWithinTransaction(() -> { - TenantMobilityJob job = findByUuid(jobUuid); + AccountMigrationJob job = findByUuid(jobUuid); if (job == null) return; if (ex != null) { @@ -282,7 +281,7 @@ private void finalizeJob(String jobUuid, Throwable ex, Path resultFile, Cancella } private tools.dynamia.modules.saas.migration.api.MigrationProgressListener buildProgressListener( - TenantMobilityJob job) { + AccountMigrationJob job) { // Mark the job as RUNNING on first progress event, then persist progress updates final boolean[] started = {false}; return (MigrationProgress p) -> { @@ -292,7 +291,7 @@ private tools.dynamia.modules.saas.migration.api.MigrationProgressListener build } try { crudService.executeWithinTransaction(() -> { - TenantMobilityJob j = findByUuid(job.getUuid()); + AccountMigrationJob j = findByUuid(job.getUuid()); if (j != null && !j.isFinished()) { j.updateProgress(p.percentage() >= 0 ? p.percentage() : j.getProgress(), p.message()); @@ -305,7 +304,7 @@ private tools.dynamia.modules.saas.migration.api.MigrationProgressListener build }; } - private Path buildOutputPath(TenantMobilityJob job, boolean compressed) { + 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"); @@ -329,17 +328,17 @@ private Path saveUploadedFile(MultipartFile file, String prefix) { } } - private TenantMobilityJob findByUuid(String uuid) { - return crudService.findSingle(TenantMobilityJob.class, + private AccountMigrationJob findByUuid(String uuid) { + return crudService.findSingle(AccountMigrationJob.class, QueryParameters.with("uuid", uuid)); } - private TenantMobilityJobDto toDto(TenantMobilityJob job) { + private AccountMigrationJobDto toDto(AccountMigrationJob job) { String downloadUrl = null; if (job.getResultPath() != null) { downloadUrl = "/api/saas/migration/jobs/" + job.getUuid() + "/download"; } - return new TenantMobilityJobDto( + return new AccountMigrationJobDto( job.getId(), job.getUuid(), job.getAccountId(), diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/TenantMobilityServiceImpl.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/AccountMigrationServiceImpl.java similarity index 79% rename from extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/TenantMobilityServiceImpl.java rename to extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/AccountMigrationServiceImpl.java index 8f69f89d..6a488007 100644 --- a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/TenantMobilityServiceImpl.java +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/AccountMigrationServiceImpl.java @@ -15,10 +15,10 @@ 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.TenantCloneOptions; -import tools.dynamia.modules.saas.migration.api.TenantExportOptions; -import tools.dynamia.modules.saas.migration.api.TenantImportOptions; -import tools.dynamia.modules.saas.migration.api.TenantMobilityService; +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; @@ -28,7 +28,7 @@ import java.io.OutputStream; /** - * Default implementation of {@link TenantMobilityService}. + * 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}) @@ -39,15 +39,15 @@ * @author Mario Serrano Leones */ @Service -public class TenantMobilityServiceImpl implements TenantMobilityService { +public class AccountMigrationServiceImpl implements AccountMigrationService { - private static final Logger log = LoggerFactory.getLogger(TenantMobilityServiceImpl.class); + private static final Logger log = LoggerFactory.getLogger(AccountMigrationServiceImpl.class); private final ExportPipeline exportPipeline; private final ImportPipeline importPipeline; - public TenantMobilityServiceImpl(ExportPipeline exportPipeline, - ImportPipeline importPipeline) { + public AccountMigrationServiceImpl(ExportPipeline exportPipeline, + ImportPipeline importPipeline) { this.exportPipeline = exportPipeline; this.importPipeline = importPipeline; } @@ -55,7 +55,7 @@ public TenantMobilityServiceImpl(ExportPipeline exportPipeline, @Override public void exportTenant(Long accountId, OutputStream output, - TenantExportOptions options, + AccountExportOptions options, MigrationProgressListener listener, CancellationToken token) { log.info("[Migration] Starting export for accountId={}", accountId); @@ -65,7 +65,7 @@ public void exportTenant(Long accountId, @Override public void importTenant(InputStream input, - TenantImportOptions options, + AccountImportOptions options, MigrationProgressListener listener, CancellationToken token) { log.info("[Migration] Starting import for targetAccountId={}", options.getTargetAccountId()); @@ -74,15 +74,15 @@ public void importTenant(InputStream input, } @Override - public void cloneTenant(TenantCloneOptions options, - MigrationProgressListener listener, - CancellationToken token) { + 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 ─────────────────────────────── - TenantExportOptions exportOptions = new TenantExportOptions() + AccountExportOptions exportOptions = new AccountExportOptions() .chunkSize(options.getChunkSize()) .identityStrategy(options.getIdentityStrategy()) .label("clone-" + source + "->" + target); @@ -100,7 +100,7 @@ public void cloneTenant(TenantCloneOptions options, } // ── Phase 2: Import from buffer ──────────────────────────────────── - TenantImportOptions importOptions = new TenantImportOptions() + AccountImportOptions importOptions = new AccountImportOptions() .targetAccountId(target) .chunkSize(options.getChunkSize()) .identityStrategy(options.getIdentityStrategy()) 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 index 4f933d2d..b1ea339f 100644 --- 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 @@ -15,8 +15,8 @@ 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.TenantCloneOptions; -import tools.dynamia.modules.saas.migration.api.TenantMobilityService; +import tools.dynamia.modules.saas.migration.api.AccountCloneOptions; +import tools.dynamia.modules.saas.migration.api.AccountMigrationService; /** * Background worker that executes a tenant clone operation @@ -35,13 +35,13 @@ public class CloneWorker extends TaskWithResult { private static final Logger log = LoggerFactory.getLogger(CloneWorker.class); - private final TenantCloneOptions options; - private final TenantMobilityService mobilityService; + private final AccountCloneOptions options; + private final AccountMigrationService mobilityService; private final MigrationProgressListener progressListener; private final CancellationToken cancellationToken; - public CloneWorker(TenantCloneOptions options, - TenantMobilityService mobilityService, + public CloneWorker(AccountCloneOptions options, + AccountMigrationService mobilityService, MigrationProgressListener progressListener, CancellationToken cancellationToken) { super("CloneWorker-" + options.getSourceAccountId() + "->" + options.getTargetAccountId()); 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 index 119e134c..fa01218a 100644 --- 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 @@ -15,8 +15,8 @@ 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.TenantExportOptions; -import tools.dynamia.modules.saas.migration.api.TenantMobilityService; +import tools.dynamia.modules.saas.migration.api.AccountExportOptions; +import tools.dynamia.modules.saas.migration.api.AccountMigrationService; import java.io.FileOutputStream; import java.io.OutputStream; @@ -26,7 +26,7 @@ * 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 TenantMobilityService#exportTenant} and + * 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. @@ -39,15 +39,15 @@ public class ExportWorker extends TaskWithResult { private final Long accountId; private final Path outputFile; - private final TenantExportOptions options; - private final TenantMobilityService mobilityService; + private final AccountExportOptions options; + private final AccountMigrationService mobilityService; private final MigrationProgressListener progressListener; private final CancellationToken cancellationToken; public ExportWorker(Long accountId, Path outputFile, - TenantExportOptions options, - TenantMobilityService mobilityService, + AccountExportOptions options, + AccountMigrationService mobilityService, MigrationProgressListener progressListener, CancellationToken cancellationToken) { super("ExportWorker-account-" + accountId); 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 index dabe6182..d3ca2afe 100644 --- 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 @@ -15,8 +15,8 @@ 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.TenantImportOptions; -import tools.dynamia.modules.saas.migration.api.TenantMobilityService; +import tools.dynamia.modules.saas.migration.api.AccountImportOptions; +import tools.dynamia.modules.saas.migration.api.AccountMigrationService; import java.io.FileInputStream; import java.io.InputStream; @@ -36,14 +36,14 @@ public class ImportWorker extends TaskWithResult { private static final Logger log = LoggerFactory.getLogger(ImportWorker.class); private final Path inputFile; - private final TenantImportOptions options; - private final TenantMobilityService mobilityService; + private final AccountImportOptions options; + private final AccountMigrationService mobilityService; private final MigrationProgressListener progressListener; private final CancellationToken cancellationToken; public ImportWorker(Path inputFile, - TenantImportOptions options, - TenantMobilityService mobilityService, + AccountImportOptions options, + AccountMigrationService mobilityService, MigrationProgressListener progressListener, CancellationToken cancellationToken) { super("ImportWorker-account-" + options.getTargetAccountId()); From 90be419125275b43a5f0ae00a34383235b2488ca Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Mon, 15 Jun 2026 13:03:55 -0500 Subject: [PATCH 4/7] Rename Tenant Mobility Module to Account Migration Module in architecture documentation --- .../saas/sources/migration/ARCHITECTURE.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/extensions/saas/sources/migration/ARCHITECTURE.md b/extensions/saas/sources/migration/ARCHITECTURE.md index 1973632c..0df123ce 100644 --- a/extensions/saas/sources/migration/ARCHITECTURE.md +++ b/extensions/saas/sources/migration/ARCHITECTURE.md @@ -1,8 +1,8 @@ -# Tenant Mobility Module — Architecture +# Account Migration Module — Architecture ## 1. Overview -The Tenant Mobility Module enables full lifecycle management of tenant (Account) data: export, import, clone, backup, and restore. It is designed for: +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. @@ -15,14 +15,14 @@ The Tenant Mobility Module enables full lifecycle management of tenant (Account) ``` ┌──────────────────────────────────────────────────────────────────────┐ -│ TenantMobilityController (REST) │ +│ AccountMigrationController (REST) │ │ POST /export POST /import POST /clone GET /status GET /download │ └─────────────────────────────┬────────────────────────────────────────┘ │ delegates to ┌─────────────────────────────▼────────────────────────────────────────┐ -│ TenantMobilityJobService │ +│ AccountMigrationJobService │ │ createJob() · cancelJob() · getJob() · listJobs() │ -│ Persists TenantMobilityJob entity in DB │ +│ Persists AccountMigrationJob entity in DB │ └──────┬───────────────────────────────────────────┬───────────────────┘ │ launches via │ saves progress via │ SchedulerUtil.runWithResult(worker) │ CrudService.update() @@ -36,7 +36,7 @@ The Tenant Mobility Module enables full lifecycle management of tenant (Account) │ └────────────┬───────────┘ │ │ calls │ ┌────────────▼───────────┐ -│ │ TenantMobilityService │ +│ │ AccountMigrationService │ │ │ (impl: coordinates │ │ │ pipelines) │ │ └────────────┬───────────┘ @@ -174,9 +174,9 @@ REGENERATE_IDS (default for clone): POST /export/{accountId} │ ▼ -TenantMobilityJobService.createExportJob(accountId, options) +AccountMigrationJobService.createExportJob(accountId, options) │ - ├── 1. Persist TenantMobilityJob{status=PENDING} + ├── 1. Persist AccountMigrationJob{status=PENDING} ├── 2. SchedulerUtil.runWithResult(new ExportWorker(jobId, accountId, options)) │ └── Virtual Thread starts │ ├── Update job status → RUNNING From c9562e3fc81a5a3c6b50169cf6c46b4dbc64d230 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Mon, 15 Jun 2026 15:37:12 -0500 Subject: [PATCH 5/7] Refactor account migration job service to support options in job creation and enhance concurrency management --- .../saas/sources/migration/ARCHITECTURE.md | 6 +- .../migration/pipeline/ExportPipeline.java | 15 --- .../migration/pipeline/ImportPipeline.java | 21 ++++- .../AccountMigrationJobServiceImpl.java | 93 +++++++++++++------ .../saas/migration/workers/CloneWorker.java | 9 +- .../saas/migration/workers/ImportWorker.java | 3 +- 6 files changed, 97 insertions(+), 50 deletions(-) diff --git a/extensions/saas/sources/migration/ARCHITECTURE.md b/extensions/saas/sources/migration/ARCHITECTURE.md index 0df123ce..6ecb2e6a 100644 --- a/extensions/saas/sources/migration/ARCHITECTURE.md +++ b/extensions/saas/sources/migration/ARCHITECTURE.md @@ -16,7 +16,7 @@ The Account Migration Module enables full lifecycle management of tenant (Accoun ``` ┌──────────────────────────────────────────────────────────────────────┐ │ AccountMigrationController (REST) │ -│ POST /export POST /import POST /clone GET /status GET /download │ +│ POST /export POST /import POST /clone GET /jobs/{jobId} GET /download │ └─────────────────────────────┬────────────────────────────────────────┘ │ delegates to ┌─────────────────────────────▼────────────────────────────────────────┐ @@ -252,6 +252,8 @@ public interface IdentityMapper { } ``` +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 | @@ -259,6 +261,8 @@ public interface IdentityMapper { | `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 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 index 3a3de222..33b79c07 100644 --- 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 @@ -316,21 +316,6 @@ private int resolveChunkSize(AccountExportOptions options) { return size > 0 ? size : properties.getChunkSize(); } - /** Count total exportable records across all entity types for progress reporting. */ - private List> buildSortedList(Long accountId, List> entities) { - List> nonEmpty = new ArrayList<>(); - for (Class ec : entities) { - if (Account.class.equals(ec)) continue; - try { - if (crudService.count(ec, QueryParameters.with("accountId", accountId)) > 0) { - nonEmpty.add(ec); - } - } catch (Exception ignored) { - nonEmpty.add(ec); - } - } - return nonEmpty; - } } 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 index 57f341fd..7873f288 100644 --- 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 @@ -22,6 +22,7 @@ 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; @@ -29,6 +30,7 @@ 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; @@ -75,6 +77,10 @@ public class ImportPipeline { @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; @@ -404,7 +410,20 @@ private static InputStream detectAndWrapGzip(InputStream in) throws IOException } private IdentityMapper resolveIdentityMapper(AccountImportOptions options) { - return switch (options.getIdentityStrategy()) { + 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(); }; 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 index a68e3f2d..12389d94 100644 --- 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 @@ -10,12 +10,15 @@ */ package tools.dynamia.modules.saas.migration.services; +import com.fasterxml.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; @@ -45,6 +48,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Semaphore; import java.util.stream.Collectors; /** @@ -73,13 +77,18 @@ public class AccountMigrationJobServiceImpl implements AccountMigrationJobServic 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) { + 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())); } // ───────────────────────────────────────────────────────────────────────── @@ -88,7 +97,7 @@ public AccountMigrationJobServiceImpl(CrudService crudService, @Override public AccountMigrationJobDto createExportJob(Long accountId, AccountExportOptions options) { - AccountMigrationJob job = createAndSaveJob(accountId, null, AccountJobType.EXPORT); + AccountMigrationJob job = createAndSaveJob(accountId, null, AccountJobType.EXPORT, options); launchExportJob(job, accountId, options); return toDto(job); } @@ -98,7 +107,7 @@ public AccountMigrationJobDto createBackupJob(Long accountId) { AccountExportOptions options = new AccountExportOptions() .compressionEnabled(properties.isCompressionEnabled()) .label("backup"); - AccountMigrationJob job = createAndSaveJob(accountId, null, AccountJobType.BACKUP); + AccountMigrationJob job = createAndSaveJob(accountId, null, AccountJobType.BACKUP, options); launchExportJob(job, accountId, options); return toDto(job); } @@ -106,7 +115,7 @@ public AccountMigrationJobDto createBackupJob(Long accountId) { @Override public AccountMigrationJobDto createImportJob(MultipartFile file, AccountImportOptions options) { Path savedFile = saveUploadedFile(file, "import"); - AccountMigrationJob job = createAndSaveJob(options.getTargetAccountId(), null, AccountJobType.IMPORT); + AccountMigrationJob job = createAndSaveJob(options.getTargetAccountId(), null, AccountJobType.IMPORT, options); launchImportJob(job, savedFile, options); return toDto(job); } @@ -117,7 +126,7 @@ public AccountMigrationJobDto createRestoreJob(Long accountId, MultipartFile fil AccountImportOptions options = new AccountImportOptions() .targetAccountId(accountId) .identityStrategy(IdentityStrategy.KEEP_IDS); - AccountMigrationJob job = createAndSaveJob(accountId, null, AccountJobType.RESTORE); + AccountMigrationJob job = createAndSaveJob(accountId, null, AccountJobType.RESTORE, options); launchImportJob(job, savedFile, options); return toDto(job); } @@ -125,7 +134,7 @@ public AccountMigrationJobDto createRestoreJob(Long accountId, MultipartFile fil @Override public AccountMigrationJobDto createCloneJob(AccountCloneOptions options) { AccountMigrationJob job = createAndSaveJob( - options.getSourceAccountId(), options.getTargetAccountId(), AccountJobType.CLONE); + options.getSourceAccountId(), options.getTargetAccountId(), AccountJobType.CLONE, options); launchCloneJob(job, options); return toDto(job); } @@ -187,49 +196,69 @@ public void cancelJob(String jobUuid) { 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); - - SchedulerUtil.runWithResult(worker).whenComplete((result, ex) -> { - activeTokens.remove(job.getUuid()); - finalizeJob(job.getUuid(), ex, outputFile, 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); - - SchedulerUtil.runWithResult(worker).whenComplete((result, ex) -> { - activeTokens.remove(job.getUuid()); - finalizeJob(job.getUuid(), ex, null, token); - // Clean up uploaded file after import - try { - Files.deleteIfExists(inputFile); - } catch (IOException ignored) { - } - }); + 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); + } - SchedulerUtil.runWithResult(worker).whenComplete((result, ex) -> { + /** + * 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, null, token); + finalizeJob(job.getUuid(), ex, resultFile, token); + if (cleanupPath != null) { + try { + Files.deleteIfExists(cleanupPath); + } catch (IOException ignored) { + } + } }); } @@ -237,12 +266,20 @@ private void launchCloneJob(AccountMigrationJob job, AccountCloneOptions options // Helpers // ───────────────────────────────────────────────────────────────────────── - private AccountMigrationJob createAndSaveJob(Long accountId, Long targetAccountId, AccountJobType type) { + 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; 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 index b1ea339f..71c09f6f 100644 --- 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 @@ -22,10 +22,11 @@ * Background worker that executes a tenant clone operation * (source account → target account, same system). * - *

    Uses an in-memory {@code PipedOutputStream / PipedInputStream} bridge so - * the export and import pipelines run sequentially without writing to disk. - * For very large datasets, consider using {@link ExportWorker} followed by - * {@link ImportWorker} with a temporary file to avoid memory pressure. + *

    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. * 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 index d3ca2afe..9579f8b4 100644 --- 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 @@ -18,6 +18,7 @@ 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; @@ -58,7 +59,7 @@ public ImportWorker(Path inputFile, public Boolean doWorkWithResult() { log.info("[Migration/Worker] Starting IMPORT from {} → accountId={}", inputFile, options.getTargetAccountId()); - try (InputStream in = new FileInputStream(inputFile.toFile())) { + 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"); From f7bf8a84680e5155f1d5c7827e70b2417bc3e68c Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Mon, 15 Jun 2026 16:11:54 -0500 Subject: [PATCH 6/7] test(migration): add unit test suite for the account migration module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers all non-trivial logic with 61 tests across 10 test classes: - AccountMigrationJobTest: entity state machine (PENDING→RUNNING→COMPLETED/FAILED/CANCELLED) - CancellationTokenTest: thread-safe cancel/isCancelled including virtual-thread visibility - KeepIdsIdentityMapperTest / RegenerateIdsIdentityMapperTest: both identity strategies - EntityDependencyGraphTest: Kahn's BFS topological sort with Mockito JPA metamodel stubs - GzipStreamTest: GZIP magic-byte detection and BufferedInputStream mark() contract (P1 fix coverage) - ExportConstantsTest: JSON format constants and minimal valid stream structure - ImportPipelineMapperResolutionTest: UUID7 guard, custom Spring bean mapper priority - OptionsFluentBuilderTest: fluent builders and Jackson round-trip for options_json - AccountMigrationPropertiesTest: defaults and semaphore floor guard Adds mockito-core:5.20.0 as test-scope dependency. Co-Authored-By: Claude Sonnet 4.6 --- extensions/saas/sources/migration/pom.xml | 7 + .../migration/AccountMigrationJobTest.java | 105 ++++++++++ .../saas/migration/CancellationTokenTest.java | 75 ++++++++ .../api/OptionsFluentBuilderTest.java | 128 ++++++++++++ .../AccountMigrationPropertiesTest.java | 65 +++++++ .../graph/EntityDependencyGraphTest.java | 182 ++++++++++++++++++ .../identity/KeepIdsIdentityMapperTest.java | 60 ++++++ .../RegenerateIdsIdentityMapperTest.java | 77 ++++++++ .../pipeline/ExportConstantsTest.java | 80 ++++++++ .../migration/pipeline/GzipStreamTest.java | 114 +++++++++++ .../ImportPipelineMapperResolutionTest.java | 182 ++++++++++++++++++ 11 files changed, 1075 insertions(+) create mode 100644 extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/AccountMigrationJobTest.java create mode 100644 extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/CancellationTokenTest.java create mode 100644 extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/api/OptionsFluentBuilderTest.java create mode 100644 extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/config/AccountMigrationPropertiesTest.java create mode 100644 extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/graph/EntityDependencyGraphTest.java create mode 100644 extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/identity/KeepIdsIdentityMapperTest.java create mode 100644 extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/identity/RegenerateIdsIdentityMapperTest.java create mode 100644 extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/pipeline/ExportConstantsTest.java create mode 100644 extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/pipeline/GzipStreamTest.java create mode 100644 extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/pipeline/ImportPipelineMapperResolutionTest.java diff --git a/extensions/saas/sources/migration/pom.xml b/extensions/saas/sources/migration/pom.xml index 5bd0799f..16057538 100644 --- a/extensions/saas/sources/migration/pom.xml +++ b/extensions/saas/sources/migration/pom.xml @@ -132,6 +132,13 @@ test + + org.mockito + mockito-core + 5.20.0 + test + + 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..e7711346 --- /dev/null +++ b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/api/OptionsFluentBuilderTest.java @@ -0,0 +1,128 @@ +/* + * 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 com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +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() + .addModule(new JavaTimeModule()) + .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..2efbfd7c --- /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 com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.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.getFactory().createGenerator(out); + + gen.writeStartObject(); + gen.writeStringField(ExportConstants.FIELD_VERSION, ExportConstants.FORMAT_VERSION); + gen.writeStringField(ExportConstants.FIELD_EXPORTED_AT, "2026-06-15T10:00:00"); + gen.writeNumberField(ExportConstants.FIELD_SOURCE_ACCOUNT_ID, 1L); + gen.writeStringField(ExportConstants.FIELD_IDENTITY_STRATEGY, "KEEP_IDS"); + gen.writeObjectFieldStart(ExportConstants.FIELD_ACCOUNT); + gen.writeEndObject(); + gen.writeObjectFieldStart(ExportConstants.FIELD_ENTITIES); + 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..817e95e7 --- /dev/null +++ b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/pipeline/ImportPipelineMapperResolutionTest.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.pipeline; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +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() + .addModule(new JavaTimeModule()) + .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)); + } +} From c377b05af6758de046bae66ad325d228fb74ad17 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Mon, 15 Jun 2026 17:01:40 -0500 Subject: [PATCH 7/7] chore(migration): migrate from Jackson 2 to Jackson 3 (tools.jackson.*) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spring Boot 4 ships Jackson 3 under the tools.jackson.* groupId. Updated pom.xml, all production sources, and test files to use the new packages and renamed API surface: - jackson-databind groupId: com.fasterxml.jackson.core → tools.jackson.core - Removed jackson-datatype-jsr310 (Java Time support is built-in to databind 3.x) - ObjectMapper: getFactory().createGenerator/Parser → createGenerator/createParser - JsonGenerator: writeFieldName → writeName, writeObject → writePOJO, writeXxxField → writeXxxProperty, writeObjectFieldStart/writeArrayFieldStart → writeName + writeStartObject/writeStartArray - JsonMapper.builder(): removed .disable(WRITE_DATES_AS_TIMESTAMPS) (ISO-8601 is the default in Jackson 3, no flag needed) - Test setUp(): removed .addModule(new JavaTimeModule()) (module no longer exists) All 61 unit tests pass. Co-Authored-By: Claude Sonnet 4.6 --- extensions/saas/sources/migration/pom.xml | 10 ++---- .../config/AccountMigrationConfig.java | 18 +++++------ .../migration/pipeline/ExportPipeline.java | 32 ++++++++++--------- .../migration/pipeline/ImportPipeline.java | 10 +++--- .../AccountMigrationJobServiceImpl.java | 2 +- .../api/OptionsFluentBuilderTest.java | 6 ++-- .../pipeline/ExportConstantsTest.java | 18 +++++------ .../ImportPipelineMapperResolutionTest.java | 6 ++-- 8 files changed, 47 insertions(+), 55 deletions(-) diff --git a/extensions/saas/sources/migration/pom.xml b/extensions/saas/sources/migration/pom.xml index 16057538..58eb7627 100644 --- a/extensions/saas/sources/migration/pom.xml +++ b/extensions/saas/sources/migration/pom.xml @@ -100,18 +100,12 @@ spring-boot-autoconfigure - + - com.fasterxml.jackson.core + tools.jackson.core jackson-databind - - - com.fasterxml.jackson.datatype - jackson-datatype-jsr310 - - jakarta.servlet 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 index 117a3f90..ac6c39a5 100644 --- 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 @@ -11,10 +11,10 @@ package tools.dynamia.modules.saas.migration.config; import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +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; @@ -53,7 +53,7 @@ public AccountMigrationConfig(AccountMigrationProperties properties) { * *

    Configured to: *

      - *
    • Support Java 8+ date/time types via {@link JavaTimeModule}.
    • + *
    • 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).
    • @@ -65,12 +65,12 @@ public AccountMigrationConfig(AccountMigrationProperties properties) { @Bean("migrationObjectMapper") @ConditionalOnMissingBean(name = "migrationObjectMapper") public ObjectMapper migrationObjectMapper() { - return new ObjectMapper() - .registerModule(new JavaTimeModule()) - .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + return JsonMapper.builder() .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) - .setSerializationInclusion(JsonInclude.Include.NON_NULL); + .changeDefaultPropertyInclusion( + i -> i.withValueInclusion(JsonInclude.Include.NON_NULL)) + .build(); } private void initOutputDirectory() { 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 index 33b79c07..46a7843b 100644 --- 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 @@ -10,8 +10,8 @@ */ package tools.dynamia.modules.saas.migration.pipeline; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.core.JsonGenerator; +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; @@ -133,21 +133,22 @@ public void export(Long accountId, throw new MigrationException("Failed to set up output stream", e); } - try (JsonGenerator gen = objectMapper.getFactory().createGenerator(target)) { + try (JsonGenerator gen = objectMapper.createGenerator(target)) { gen.writeStartObject(); - gen.writeStringField(ExportConstants.FIELD_VERSION, ExportConstants.FORMAT_VERSION); - gen.writeStringField(ExportConstants.FIELD_EXPORTED_AT, LocalDateTime.now().toString()); - gen.writeNumberField(ExportConstants.FIELD_SOURCE_ACCOUNT_ID, accountId); - gen.writeStringField(ExportConstants.FIELD_IDENTITY_STRATEGY, + 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.writeFieldName(ExportConstants.FIELD_ACCOUNT); + gen.writeName(ExportConstants.FIELD_ACCOUNT); objectMapper.writeValue(gen, account.toDTO()); // Entity section - gen.writeObjectFieldStart(ExportConstants.FIELD_ENTITIES); + gen.writeName(ExportConstants.FIELD_ENTITIES); + gen.writeStartObject(); long processed = 0; for (Class entityClass : ordered) { @@ -189,7 +190,8 @@ private long exportEntityType(JsonGenerator gen, Class entityClass, Long acco long processed = 0; int chunkSize = resolveChunkSize(options); - gen.writeArrayFieldStart(entityClass.getName()); + gen.writeName(entityClass.getName()); + gen.writeStartArray(); try { EntityType entityType = emf.getMetamodel().entity(entityClass); @@ -244,8 +246,8 @@ private void writeEntity(JsonGenerator gen, Object entity, EntityType entityT // Write ID explicitly try { Serializable id = JpaUtils.getJPAIdValue(entity); - gen.writeFieldName("id"); - gen.writeObject(id); + gen.writeName("id"); + gen.writePOJO(id); } catch (Exception e) { log.debug("[Migration/Export] Could not write ID for {}", entity.getClass().getSimpleName()); } @@ -270,8 +272,8 @@ private void writeEntity(JsonGenerator gen, Object entity, EntityType entityT || pt == PersistentAttributeType.ONE_TO_ONE) { if (value != null) { Serializable refId = JpaUtils.getJPAIdValue(value); - gen.writeFieldName(name + ExportConstants.REF_ID_SUFFIX); - gen.writeObject(refId); + gen.writeName(name + ExportConstants.REF_ID_SUFFIX); + gen.writePOJO(refId); } } else if (pt == PersistentAttributeType.ONE_TO_MANY || pt == PersistentAttributeType.MANY_TO_MANY @@ -279,7 +281,7 @@ private void writeEntity(JsonGenerator gen, Object entity, EntityType entityT // Skip collections — they are reconstructed via child entities } else { // BASIC or EMBEDDED - gen.writeFieldName(name); + gen.writeName(name); objectMapper.writeValue(gen, value); } 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 index 7873f288..3ead6778 100644 --- 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 @@ -10,10 +10,10 @@ */ package tools.dynamia.modules.saas.migration.pipeline; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.core.JsonToken; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +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; @@ -115,7 +115,7 @@ public void importTenant(InputStream input, throw new MigrationException("Failed to open input stream", e); } - try (JsonParser parser = objectMapper.getFactory().createParser(source)) { + try (JsonParser parser = objectMapper.createParser(source)) { expectToken(parser, JsonToken.START_OBJECT, "root object"); long totalProcessed = 0; 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 index 12389d94..c2931e35 100644 --- 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 @@ -10,7 +10,7 @@ */ package tools.dynamia.modules.saas.migration.services; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; 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 index e7711346..16633aa9 100644 --- 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 @@ -10,9 +10,8 @@ */ package tools.dynamia.modules.saas.migration.api; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.json.JsonMapper; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; import org.junit.Assert; import org.junit.Before; import org.junit.Test; @@ -28,7 +27,6 @@ public class OptionsFluentBuilderTest { @Before public void setUp() { objectMapper = JsonMapper.builder() - .addModule(new JavaTimeModule()) .build(); } 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 index 2efbfd7c..70806367 100644 --- 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 @@ -10,8 +10,8 @@ */ package tools.dynamia.modules.saas.migration.pipeline; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import org.junit.Assert; import org.junit.Test; @@ -56,16 +56,16 @@ public void minimalExportJsonStructureIsValid() throws IOException { // Build the minimal JSON skeleton that ImportPipeline expects ObjectMapper mapper = new ObjectMapper(); ByteArrayOutputStream out = new ByteArrayOutputStream(); - var gen = mapper.getFactory().createGenerator(out); + var gen = mapper.createGenerator(out); gen.writeStartObject(); - gen.writeStringField(ExportConstants.FIELD_VERSION, ExportConstants.FORMAT_VERSION); - gen.writeStringField(ExportConstants.FIELD_EXPORTED_AT, "2026-06-15T10:00:00"); - gen.writeNumberField(ExportConstants.FIELD_SOURCE_ACCOUNT_ID, 1L); - gen.writeStringField(ExportConstants.FIELD_IDENTITY_STRATEGY, "KEEP_IDS"); - gen.writeObjectFieldStart(ExportConstants.FIELD_ACCOUNT); + 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.writeObjectFieldStart(ExportConstants.FIELD_ENTITIES); + gen.writeName(ExportConstants.FIELD_ENTITIES); gen.writeStartObject(); gen.writeEndObject(); gen.writeEndObject(); gen.close(); 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 index 817e95e7..3618a24b 100644 --- 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 @@ -10,9 +10,8 @@ */ package tools.dynamia.modules.saas.migration.pipeline; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.json.JsonMapper; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; import jakarta.persistence.EntityManagerFactory; import org.junit.Assert; import org.junit.Before; @@ -59,7 +58,6 @@ public class ImportPipelineMapperResolutionTest { public void setUp() { properties = new AccountMigrationProperties(); objectMapper = JsonMapper.builder() - .addModule(new JavaTimeModule()) .build(); // Minimal metamodel: getEntities() returns empty set so no entity processing occurs