From 3dcf1d0f7743beff10dfaffcf9c2c4a12f80a8d4 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 24 Sep 2025 09:31:44 +0100 Subject: [PATCH 01/15] feat(database): add migration base class for database schema evolution - Introduce abstract Migration class for defining database migrations - Require implementation of 'up' and 'down' methods for idempotent migrations - Include versioning, description, and optional GitHub PR reference for each migration - Facilitate chronological ordering and rollback capabilities in database schema management --- lib/src/database/migration.dart | 55 +++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 lib/src/database/migration.dart diff --git a/lib/src/database/migration.dart b/lib/src/database/migration.dart new file mode 100644 index 0000000..50557d6 --- /dev/null +++ b/lib/src/database/migration.dart @@ -0,0 +1,55 @@ +import 'package:logging/logging.dart'; +import 'package:mongo_dart/mongo_dart.dart'; + +/// {@template migration} +/// An abstract base class for defining database migration scripts. +/// +/// Each concrete migration must extend this class and implement the [up] and +/// [down] methods. Migrations are identified by a unique [version] string +/// (following the `YYYYMMDDHHMMSS` format) and a [description]. +/// +/// Implementations of [up] and [down] must be **idempotent**, meaning they +/// can be safely run multiple times without causing errors or incorrect data. +/// This is crucial for robust database schema evolution. +/// {@endtemplate} +abstract class Migration { + /// {@macro migration} + const Migration({ + required this.version, + required this.description, + this.gitHubPullRequest, + }); + + /// A unique identifier for the migration, following the `YYYYMMDDHHMMSS` + /// format (e.g., '20250924083500'). This ensures chronological ordering. + final String version; + + /// A human-readable description of the migration's purpose. + final String description; + + /// An optional URL or identifier for the GitHub Pull Request that introduced + /// the schema changes addressed by this migration. + /// + /// This provides valuable context for future maintainers, linking the + /// database migration directly to the code changes that necessitated it. + final String? gitHubPullRequest; + + /// Applies the migration, performing necessary schema changes or data + /// transformations. + /// + /// This method is executed when the migration is run. It receives the + /// MongoDB [db] instance and a [Logger] for logging progress and errors. + /// + /// Implementations **must** be idempotent. + Future up(Db db, Logger log); + + /// Reverts the migration, undoing the changes made by the [up] method. + /// + /// This method is executed when a migration needs to be rolled back. It + /// receives the MongoDB [db] instance and a [Logger]. + /// + /// Implementations **must** be idempotent. While optional for simple + /// forward-only migrations, providing a `down` method is a best practice + /// for professional systems to enable rollback capabilities. + Future down(Db db, Logger log); +} From a95ffcaed3ae8681878670226d8b5822e32956e2 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 24 Sep 2025 09:35:45 +0100 Subject: [PATCH 02/15] feat(database): add migration to refactor adConfig to role-based structure - Implement RefactorAdConfigToRoleBased migration - Transform adConfig structure in RemoteConfig documents - Replace old ad frequency fields with new visibleTo maps - Update FeedAdConfiguration, ArticleAdConfiguration, and InterstitialAdConfiguration - Ensure backward compatibility in down migration --- ...800__refactor_ad_config_to_role_based.dart | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 lib/src/database/migrations/20250924084800__refactor_ad_config_to_role_based.dart diff --git a/lib/src/database/migrations/20250924084800__refactor_ad_config_to_role_based.dart b/lib/src/database/migrations/20250924084800__refactor_ad_config_to_role_based.dart new file mode 100644 index 0000000..7ed25f6 --- /dev/null +++ b/lib/src/database/migrations/20250924084800__refactor_ad_config_to_role_based.dart @@ -0,0 +1,140 @@ +import 'package:core/core.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/database/migration.dart'; +import 'package:logging/logging.dart'; +import 'package:mongo_dart/mongo_dart.dart'; + +/// {@template refactor_ad_config_to_role_based} +/// A comprehensive migration to refactor the `adConfig` structure within +/// `RemoteConfig` documents to a new role-based `visibleTo` map approach. +/// +/// This migration addresses significant changes introduced by a PR (see +/// [gitHubPullRequest]) that aimed to enhance flexibility and maintainability +/// of ad configurations. It transforms old, role-specific ad frequency and +/// placement fields into new `visibleTo` maps for `FeedAdConfiguration`, +/// `ArticleAdConfiguration`, and `InterstitialAdConfiguration`. +/// +/// The migration ensures that existing `RemoteConfig` documents are updated +/// to conform to the latest model structure, preventing deserialization errors +/// and enabling granular control over ad display for different user roles. +/// {@endtemplate} +class RefactorAdConfigToRoleBased extends Migration { + /// {@macro refactor_ad_config_to_role_based} + RefactorAdConfigToRoleBased() + : super( + version: '20250924084800', + description: 'Refactor adConfig to use role-based visibleTo maps', + gitHubPullRequest: + 'https://github.com/flutter-news-app-full-source-code/core/pull/50', + ); + + @override + Future up(Db db, Logger log) async { + log.info('Applying migration: Refactor adConfig to role-based visibleTo maps.'); + + final remoteConfigCollection = db.collection('remote_configs'); + + // Define default FeedAdFrequencyConfig for roles + const defaultGuestFeedAdFrequency = + FeedAdFrequencyConfig(adFrequency: 5, adPlacementInterval: 3); + const defaultStandardUserFeedAdFrequency = + FeedAdFrequencyConfig(adFrequency: 10, adPlacementInterval: 5); + const defaultPremiumUserFeedAdFrequency = + FeedAdFrequencyConfig(adFrequency: 0, adPlacementInterval: 0); + + // Define default InterstitialAdFrequencyConfig for roles + const defaultGuestInterstitialAdFrequency = + InterstitialAdFrequencyConfig(transitionsBeforeShowingInterstitialAds: 5); + const defaultStandardUserInterstitialAdFrequency = + InterstitialAdFrequencyConfig(transitionsBeforeShowingInterstitialAds: 10); + const defaultPremiumUserInterstitialAdFrequency = + InterstitialAdFrequencyConfig(transitionsBeforeShowingInterstitialAds: 50000); + + // Define default ArticleAdSlot visibility for roles + final defaultArticleAdSlots = { + InArticleAdSlotType.aboveArticleContinueReadingButton.name: true, + InArticleAdSlotType.belowArticleContinueReadingButton.name: true, + }; + + final result = await remoteConfigCollection.updateMany( + // Find documents that still have the old structure (e.g., old frequency fields) + where.exists('adConfig.feedAdConfiguration.frequencyConfig.guestAdFrequency'), + ModifierBuilder() + // --- FeedAdConfiguration Transformation --- + // Remove old frequencyConfig fields + ..unset('adConfig.feedAdConfiguration.frequencyConfig.guestAdFrequency') + ..unset('adConfig.feedAdConfiguration.frequencyConfig.guestAdPlacementInterval') + ..unset('adConfig.feedAdConfiguration.frequencyConfig.authenticatedAdFrequency') + ..unset('adConfig.feedAdConfiguration.frequencyConfig.authenticatedAdPlacementInterval') + ..unset('adConfig.feedAdConfiguration.frequencyConfig.premiumAdFrequency') + ..unset('adConfig.feedAdConfiguration.frequencyConfig.premiumAdPlacementInterval') + // Set the new visibleTo map for FeedAdConfiguration + ..set( + 'adConfig.feedAdConfiguration.visibleTo', + { + AppUserRole.guestUser.name: defaultGuestFeedAdFrequency.toJson(), + AppUserRole.standardUser.name: + defaultStandardUserFeedAdFrequency.toJson(), + AppUserRole.premiumUser.name: + defaultPremiumUserFeedAdFrequency.toJson(), + }, + ) + // --- ArticleAdConfiguration Transformation --- + // Remove old inArticleAdSlotConfigurations list + ..unset('adConfig.articleAdConfiguration.inArticleAdSlotConfigurations') + // Set the new visibleTo map for ArticleAdConfiguration + ..set( + 'adConfig.articleAdConfiguration.visibleTo', + { + AppUserRole.guestUser.name: defaultArticleAdSlots, + AppUserRole.standardUser.name: defaultArticleAdSlots, + AppUserRole.premiumUser.name: defaultArticleAdSlots, + }, + ) + // --- InterstitialAdConfiguration Transformation --- + // Remove old feedInterstitialAdFrequencyConfig fields + ..unset('adConfig.interstitialAdConfiguration.feedInterstitialAdFrequencyConfig.guestTransitionsBeforeShowingInterstitialAds') + ..unset('adConfig.interstitialAdConfiguration.feedInterstitialAdFrequencyConfig.standardUserTransitionsBeforeShowingInterstitialAds') + ..unset('adConfig.interstitialAdConfiguration.feedInterstitialAdFrequencyConfig.premiumUserTransitionsBeforeShowingInterstitialAds') + // Set the new visibleTo map for InterstitialAdConfiguration + ..set( + 'adConfig.interstitialAdConfiguration.visibleTo', + { + AppUserRole.guestUser.name: + defaultGuestInterstitialAdFrequency.toJson(), + AppUserRole.standardUser.name: + defaultStandardUserInterstitialAdFrequency.toJson(), + AppUserRole.premiumUser.name: + defaultPremiumUserInterstitialAdFrequency.toJson(), + }, + ), + ); + + log.info( + 'Updated ${result.nModified} remote_config documents ' + 'to new role-based adConfig structure.', + ); + } + + @override + Future down(Db db, Logger log) async { + log.warning( + 'Reverting migration: Revert adConfig to old structure ' + '(not recommended for production).', + ); + // This down migration is complex and primarily for development/testing rollback. + // Reverting to the old structure would require re-introducing the old fields + // and potentially losing data if the new structure was used. + // For simplicity in this example, we'll just unset the new fields. + final result = await db.collection('remote_configs').updateMany( + where.exists('adConfig.feedAdConfiguration.visibleTo'), + ModifierBuilder() + ..unset('adConfig.feedAdConfiguration.visibleTo') + ..unset('adConfig.articleAdConfiguration.visibleTo') + ..unset('adConfig.interstitialAdConfiguration.visibleTo'), + ); + log.warning( + 'Reverted ${result.nModified} remote_config documents ' + 'by unsetting new adConfig fields.', + ); + } +} From f447df9af4798747bcc005953af0fb8e61c23ae2 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 24 Sep 2025 09:35:54 +0100 Subject: [PATCH 03/15] feat(database): create central list for all database migrations - Introduce `allMigrations` list to store all database migration classes - Add initial migration for refactoring ad config to role-based system - Include necessary imports for migration functionality --- lib/src/database/migrations/all_migrations.dart | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 lib/src/database/migrations/all_migrations.dart diff --git a/lib/src/database/migrations/all_migrations.dart b/lib/src/database/migrations/all_migrations.dart new file mode 100644 index 0000000..af15c4d --- /dev/null +++ b/lib/src/database/migrations/all_migrations.dart @@ -0,0 +1,11 @@ +import 'package:flutter_news_app_api_server_full_source_code/src/database/migration.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/database/migrations/20250924084800__refactor_ad_config_to_role_based.dart'; + +/// A central list of all database migrations to be applied. +/// +/// New migration classes should be added to this list in the order they are +/// created. The [DatabaseMigrationService] will automatically sort and apply +/// them based on their version. +final List allMigrations = [ + RefactorAdConfigToRoleBased(), +]; From 265100622e426ff51f1af7764e3289cfedb94390 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 24 Sep 2025 09:36:10 +0100 Subject: [PATCH 04/15] feat(database): implement database migration service - Add DatabaseMigrationService to manage and execute database migrations - Implement migration process: ensure migrations_history collection, fetch applied versions, sort migrations, apply pending migrations, record applied migrations - Use unique index on version field for idempotency and prevent redundant execution - Log migration process and handle failures --- .../services/database_migration_service.dart | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 lib/src/services/database_migration_service.dart diff --git a/lib/src/services/database_migration_service.dart b/lib/src/services/database_migration_service.dart new file mode 100644 index 0000000..2318ea2 --- /dev/null +++ b/lib/src/services/database_migration_service.dart @@ -0,0 +1,118 @@ +import 'package:flutter_news_app_api_server_full_source_code/src/database/migration.dart'; +import 'package:logging/logging.dart'; +import 'package:mongo_dart/mongo_dart.dart'; + +/// {@template database_migration_service} +/// A service responsible for managing and executing database migrations. +/// +/// This service discovers, sorts, and applies pending [Migration] scripts +/// to the MongoDB database. It tracks applied migrations in a dedicated +/// `migrations_history` collection to ensure idempotency and prevent +/// redundant execution. +/// +/// Migrations are identified by a unique version string (YYYYMMDDHHMMSS) +/// and are always applied in chronological order. +/// {@endtemplate} +class DatabaseMigrationService { + /// {@macro database_migration_service} + DatabaseMigrationService({ + required Db db, + required Logger log, + required List migrations, + }) : _db = db, + _log = log, + _migrations = migrations; + + final Db _db; + final Logger _log; + final List _migrations; + + /// The name of the MongoDB collection used to track applied migrations. + static const String _migrationsCollectionName = 'migrations_history'; + + /// Initializes the migration service and applies any pending migrations. + /// + /// This method performs the following steps: + /// 1. Ensures the `migrations_history` collection exists and has a unique + /// index on the `version` field. + /// 2. Fetches all previously applied migration versions from the database. + /// 3. Sorts the registered migrations by their version string. + /// 4. Iterates through the sorted migrations, applying only those that + /// have not yet been applied. + /// 5. Records each successfully applied migration in the `migrations_history` + /// collection. + Future init() async { + _log.info('Starting database migration process...'); + + await _ensureMigrationsCollectionAndIndex(); + + final appliedVersions = await _getAppliedMigrationVersions(); + _log.fine('Applied migration versions: $appliedVersions'); + + // Sort migrations by version to ensure chronological application. + _migrations.sort((a, b) => a.version.compareTo(b.version)); + + for (final migration in _migrations) { + if (!appliedVersions.contains(migration.version)) { + _log.info( + 'Applying migration V${migration.version}: ${migration.description}', + ); + try { + await migration.up(_db, _log); + await _recordMigration(migration.version); + _log.info( + 'Successfully applied migration V${migration.version}.', + ); + } catch (e, s) { + _log.severe( + 'Failed to apply migration V${migration.version}: ' + '${migration.description}', + e, + s, + ); + // Re-throw to halt application startup if a migration fails. + rethrow; + } + } else { + _log.fine( + 'Migration V${migration.version} already applied. Skipping.', + ); + } + } + + _log.info('Database migration process completed.'); + } + + /// Ensures the `migrations_history` collection exists and has a unique index + /// on the `version` field. + Future _ensureMigrationsCollectionAndIndex() async { + _log.fine('Ensuring migrations_history collection and index...'); + final collection = _db.collection(_migrationsCollectionName); + await collection.createIndex( + key: 'version', + unique: true, + name: 'version_unique_index', + ); + _log.fine('Migrations_history collection and index ensured.'); + } + + /// Retrieves a set of versions of all migrations that have already been + /// applied to the database. + Future> _getAppliedMigrationVersions() async { + final collection = _db.collection(_migrationsCollectionName); + final documents = await collection.find().toList(); + return documents + .map((doc) => doc['version'] as String) + .toSet(); + } + + /// Records a successfully applied migration in the `migrations_history` + /// collection. + Future _recordMigration(String version) async { + final collection = _db.collection(_migrationsCollectionName); + await collection.insertOne({ + 'version': version, + 'appliedAt': DateTime.now().toUtc(), + }); + } +} From d3c1a59fbd2ed69acee2a122f2e83d375392c231 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 24 Sep 2025 09:36:25 +0100 Subject: [PATCH 05/15] feat(database): add database migration service and update seeding process - Add DatabaseMigrationService to handle database schema migrations - Include all_migrations.dart in the import list - Update AppDependencies to initialize and run database migrations - Modify the application startup process to apply migrations before seeding --- lib/src/config/app_dependencies.dart | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index 76dcff2..5af2dec 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -9,8 +9,10 @@ import 'package:flutter_news_app_api_server_full_source_code/src/config/environm import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permission_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/auth_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/auth_token_service.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/database/migrations/all_migrations.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/country_query_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/dashboard_summary_service.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/database_migration_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/database_seeding_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/default_user_preference_limit_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/jwt_auth_token_service.dart'; @@ -63,6 +65,7 @@ class AppDependencies { late final EmailRepository emailRepository; // Services + late final DatabaseMigrationService databaseMigrationService; late final TokenBlacklistService tokenBlacklistService; late final AuthTokenService authTokenService; late final VerificationCodeStorageService verificationCodeStorageService; @@ -92,7 +95,17 @@ class AppDependencies { await _mongoDbConnectionManager.init(EnvironmentConfig.databaseUrl); _log.info('MongoDB connection established.'); - // 2. Seed Database + // 2. Initialize and Run Database Migrations + databaseMigrationService = DatabaseMigrationService( + db: _mongoDbConnectionManager.db, + log: Logger('DatabaseMigrationService'), + migrations: allMigrations, // From lib/src/database/migrations/all_migrations.dart + ); + await databaseMigrationService.init(); + _log.info('Database migrations applied.'); + + // 3. Seed Database + // This runs AFTER migrations to ensure the schema is up-to-date. final seedingService = DatabaseSeedingService( db: _mongoDbConnectionManager.db, log: Logger('DatabaseSeedingService'), @@ -100,7 +113,7 @@ class AppDependencies { await seedingService.seedInitialData(); _log.info('Database seeding complete.'); - // 3. Initialize Data Clients (MongoDB implementation) + // 4. Initialize Data Clients (MongoDB implementation) final headlineClient = DataMongodb( connectionManager: _mongoDbConnectionManager, modelName: 'headlines', From 4a27fe28c8c915628e1df8c0fec666c752e5fbfc Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 24 Sep 2025 09:36:36 +0100 Subject: [PATCH 06/15] refactor(database): improve RemoteConfig seeding and logging - Correct typo in adConfig comment - Enhance log message for existing RemoteConfig - Clarify RemoteConfig setup process in comments --- lib/src/services/database_seeding_service.dart | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 5121718..dd139da 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -128,7 +128,7 @@ class DatabaseSeedingService { final initialConfig = remoteConfigsFixturesData.first; // Ensure primaryAdPlatform is not 'demo' for initial setup - // sic its not intended for any use outside teh mobile client code. + // since its not intended for any use outside the mobile client. final productionReadyAdConfig = initialConfig.adConfig.copyWith( primaryAdPlatform: AdPlatformType.local, ); @@ -145,7 +145,10 @@ class DatabaseSeedingService { }); _log.info('Initial RemoteConfig created successfully.'); } else { - _log.info('RemoteConfig already exists. Skipping creation.'); + _log.info( + 'RemoteConfig already exists. Skipping creation. ' + 'Schema updates are handled by DatabaseMigrationService.', + ); } } on Exception catch (e, s) { _log.severe('Failed to seed RemoteConfig.', e, s); From 6002c18a7e40db78889d7b2b74ad00507dc4a5fc Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 24 Sep 2025 09:36:45 +0100 Subject: [PATCH 07/15] docs(README): add section on automated database migrations - Explain the new database migration system using a versioned approach - Highlight key features like idempotency and generic model support - Describe the date-time based naming convention for migrations - Emphasize the benefits of automated schema evolution for deployments --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 49d6611..d16f34a 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,14 @@ Click on any category to explore. - **Secure & Flexible:** Manages all sensitive keys, API credentials, and environment-specific settings through a `.env` file, keeping your secrets out of the codebase. > **Your Advantage:** Deploy your application across different environments (local, staging, production) safely and efficiently. +--- + +### 🔄 Automated Database Migrations +- **Versioned Schema Evolution:** Implements a robust, versioned database migration system that automatically applies schema changes to MongoDB on application startup. +- **Idempotent & Generic:** Each migration is idempotent and designed to handle schema evolution for *any* model in the database, ensuring data consistency across deployments. +- **Date-Time Based Versioning:** Migrations are named using a `YYYYMMDDHHMMSS__.dart` format, guaranteeing chronological execution and clear context. +> **Your Advantage:** Say goodbye to manual database updates! Your application gracefully handles schema changes, providing a professional and reliable mechanism for evolving your data models without breaking existing data. + ## 🔑 Licensing From 860f8c1fa5be811e66652ba5b8fed723fdcb70bd Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 24 Sep 2025 10:20:45 +0100 Subject: [PATCH 08/15] refactor(database): enhance Migration class with PR metadata - Replace 'version' with 'prDate' to reflect the merge date of the PR - Rename 'description' to 'prSummary' for consistency - Add 'prId' as a required field for unique PR identification - Update documentation to reflect new fields and their purpose --- lib/src/database/migration.dart | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/lib/src/database/migration.dart b/lib/src/database/migration.dart index 50557d6..8b4cb65 100644 --- a/lib/src/database/migration.dart +++ b/lib/src/database/migration.dart @@ -15,24 +15,30 @@ import 'package:mongo_dart/mongo_dart.dart'; abstract class Migration { /// {@macro migration} const Migration({ - required this.version, - required this.description, - this.gitHubPullRequest, + required this.prDate, + required this.prSummary, + required this.prId, }); - /// A unique identifier for the migration, following the `YYYYMMDDHHMMSS` - /// format (e.g., '20250924083500'). This ensures chronological ordering. - final String version; + /// The merge date and time of the Pull Request that introduced this + /// migration, in `YYYYMMDDHHMMSS` format (e.g., '20250924083500'). + /// + /// This serves as the unique, chronological identifier for the migration, + /// ensuring that migrations are applied in the correct order. + final String prDate; - /// A human-readable description of the migration's purpose. - final String description; + /// A concise summary of the changes introduced by the Pull Request that + /// this migration addresses. + /// + /// This provides a human-readable description of the migration's purpose. + final String prSummary; - /// An optional URL or identifier for the GitHub Pull Request that introduced - /// the schema changes addressed by this migration. + /// The unique identifier of the GitHub Pull Request that introduced the + /// schema changes addressed by this migration (e.g., '50'). /// - /// This provides valuable context for future maintainers, linking the - /// database migration directly to the code changes that necessitated it. - final String? gitHubPullRequest; + /// This provides direct traceability, linking the database migration to the + /// specific code changes on GitHub. + final String prId; /// Applies the migration, performing necessary schema changes or data /// transformations. From 1eec653623826b552177840322b8fe7791a2ab16 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 24 Sep 2025 10:25:48 +0100 Subject: [PATCH 09/15] refactor(database): update migration class and log message - Update RefactorAdConfigToRoleBased migration class constructor parameters - Improve log message in up() method to include PR ID and date --- ...800__refactor_ad_config_to_role_based.dart | 94 +++++++++++-------- 1 file changed, 56 insertions(+), 38 deletions(-) diff --git a/lib/src/database/migrations/20250924084800__refactor_ad_config_to_role_based.dart b/lib/src/database/migrations/20250924084800__refactor_ad_config_to_role_based.dart index 7ed25f6..1e1353b 100644 --- a/lib/src/database/migrations/20250924084800__refactor_ad_config_to_role_based.dart +++ b/lib/src/database/migrations/20250924084800__refactor_ad_config_to_role_based.dart @@ -20,34 +20,37 @@ import 'package:mongo_dart/mongo_dart.dart'; class RefactorAdConfigToRoleBased extends Migration { /// {@macro refactor_ad_config_to_role_based} RefactorAdConfigToRoleBased() - : super( - version: '20250924084800', - description: 'Refactor adConfig to use role-based visibleTo maps', - gitHubPullRequest: - 'https://github.com/flutter-news-app-full-source-code/core/pull/50', - ); + : super( + prDate: '20250924084800', + prSummary: 'Refactor adConfig to use role-based visibleTo maps', + prId: '50', + ); @override Future up(Db db, Logger log) async { - log.info('Applying migration: Refactor adConfig to role-based visibleTo maps.'); + log.info( + 'Applying migration PR#$prId (Date: $prDate): $prSummary.', + ); final remoteConfigCollection = db.collection('remote_configs'); // Define default FeedAdFrequencyConfig for roles - const defaultGuestFeedAdFrequency = - FeedAdFrequencyConfig(adFrequency: 5, adPlacementInterval: 3); - const defaultStandardUserFeedAdFrequency = - FeedAdFrequencyConfig(adFrequency: 10, adPlacementInterval: 5); - const defaultPremiumUserFeedAdFrequency = - FeedAdFrequencyConfig(adFrequency: 0, adPlacementInterval: 0); - + const defaultGuestFeedAdFrequency = FeedAdFrequencyConfig( + adFrequency: 5, + adPlacementInterval: 3, + ); + const defaultStandardUserFeedAdFrequency = FeedAdFrequencyConfig( + adFrequency: 10, + adPlacementInterval: 5, + ); // Define default InterstitialAdFrequencyConfig for roles - const defaultGuestInterstitialAdFrequency = - InterstitialAdFrequencyConfig(transitionsBeforeShowingInterstitialAds: 5); + const defaultGuestInterstitialAdFrequency = InterstitialAdFrequencyConfig( + transitionsBeforeShowingInterstitialAds: 5, + ); const defaultStandardUserInterstitialAdFrequency = - InterstitialAdFrequencyConfig(transitionsBeforeShowingInterstitialAds: 10); - const defaultPremiumUserInterstitialAdFrequency = - InterstitialAdFrequencyConfig(transitionsBeforeShowingInterstitialAds: 50000); + InterstitialAdFrequencyConfig( + transitionsBeforeShowingInterstitialAds: 10, + ); // Define default ArticleAdSlot visibility for roles final defaultArticleAdSlots = { @@ -57,25 +60,35 @@ class RefactorAdConfigToRoleBased extends Migration { final result = await remoteConfigCollection.updateMany( // Find documents that still have the old structure (e.g., old frequency fields) - where.exists('adConfig.feedAdConfiguration.frequencyConfig.guestAdFrequency'), + where.exists( + 'adConfig.feedAdConfiguration.frequencyConfig.guestAdFrequency', + ), ModifierBuilder() // --- FeedAdConfiguration Transformation --- // Remove old frequencyConfig fields ..unset('adConfig.feedAdConfiguration.frequencyConfig.guestAdFrequency') - ..unset('adConfig.feedAdConfiguration.frequencyConfig.guestAdPlacementInterval') - ..unset('adConfig.feedAdConfiguration.frequencyConfig.authenticatedAdFrequency') - ..unset('adConfig.feedAdConfiguration.frequencyConfig.authenticatedAdPlacementInterval') - ..unset('adConfig.feedAdConfiguration.frequencyConfig.premiumAdFrequency') - ..unset('adConfig.feedAdConfiguration.frequencyConfig.premiumAdPlacementInterval') + ..unset( + 'adConfig.feedAdConfiguration.frequencyConfig.guestAdPlacementInterval', + ) + ..unset( + 'adConfig.feedAdConfiguration.frequencyConfig.authenticatedAdFrequency', + ) + ..unset( + 'adConfig.feedAdConfiguration.frequencyConfig.authenticatedAdPlacementInterval', + ) + ..unset( + 'adConfig.feedAdConfiguration.frequencyConfig.premiumAdFrequency', + ) + ..unset( + 'adConfig.feedAdConfiguration.frequencyConfig.premiumAdPlacementInterval', + ) // Set the new visibleTo map for FeedAdConfiguration ..set( 'adConfig.feedAdConfiguration.visibleTo', { AppUserRole.guestUser.name: defaultGuestFeedAdFrequency.toJson(), - AppUserRole.standardUser.name: - defaultStandardUserFeedAdFrequency.toJson(), - AppUserRole.premiumUser.name: - defaultPremiumUserFeedAdFrequency.toJson(), + AppUserRole.standardUser.name: defaultStandardUserFeedAdFrequency + .toJson(), }, ) // --- ArticleAdConfiguration Transformation --- @@ -87,24 +100,27 @@ class RefactorAdConfigToRoleBased extends Migration { { AppUserRole.guestUser.name: defaultArticleAdSlots, AppUserRole.standardUser.name: defaultArticleAdSlots, - AppUserRole.premiumUser.name: defaultArticleAdSlots, }, ) // --- InterstitialAdConfiguration Transformation --- // Remove old feedInterstitialAdFrequencyConfig fields - ..unset('adConfig.interstitialAdConfiguration.feedInterstitialAdFrequencyConfig.guestTransitionsBeforeShowingInterstitialAds') - ..unset('adConfig.interstitialAdConfiguration.feedInterstitialAdFrequencyConfig.standardUserTransitionsBeforeShowingInterstitialAds') - ..unset('adConfig.interstitialAdConfiguration.feedInterstitialAdFrequencyConfig.premiumUserTransitionsBeforeShowingInterstitialAds') + ..unset( + 'adConfig.interstitialAdConfiguration.feedInterstitialAdFrequencyConfig.guestTransitionsBeforeShowingInterstitialAds', + ) + ..unset( + 'adConfig.interstitialAdConfiguration.feedInterstitialAdFrequencyConfig.standardUserTransitionsBeforeShowingInterstitialAds', + ) + ..unset( + 'adConfig.interstitialAdConfiguration.feedInterstitialAdFrequencyConfig.premiumUserTransitionsBeforeShowingInterstitialAds', + ) // Set the new visibleTo map for InterstitialAdConfiguration ..set( 'adConfig.interstitialAdConfiguration.visibleTo', { - AppUserRole.guestUser.name: - defaultGuestInterstitialAdFrequency.toJson(), + AppUserRole.guestUser.name: defaultGuestInterstitialAdFrequency + .toJson(), AppUserRole.standardUser.name: defaultStandardUserInterstitialAdFrequency.toJson(), - AppUserRole.premiumUser.name: - defaultPremiumUserInterstitialAdFrequency.toJson(), }, ), ); @@ -125,7 +141,9 @@ class RefactorAdConfigToRoleBased extends Migration { // Reverting to the old structure would require re-introducing the old fields // and potentially losing data if the new structure was used. // For simplicity in this example, we'll just unset the new fields. - final result = await db.collection('remote_configs').updateMany( + final result = await db + .collection('remote_configs') + .updateMany( where.exists('adConfig.feedAdConfiguration.visibleTo'), ModifierBuilder() ..unset('adConfig.feedAdConfiguration.visibleTo') From a4ec1ec4b99877db152a707e279b7dd2e800fa8e Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 24 Sep 2025 10:26:04 +0100 Subject: [PATCH 10/15] docs(database): update migration documentation - Clarify the process for adding new migration classes - Update explanation of how migrations are sorted and applied --- lib/src/database/migrations/all_migrations.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/src/database/migrations/all_migrations.dart b/lib/src/database/migrations/all_migrations.dart index af15c4d..a45edea 100644 --- a/lib/src/database/migrations/all_migrations.dart +++ b/lib/src/database/migrations/all_migrations.dart @@ -3,9 +3,9 @@ import 'package:flutter_news_app_api_server_full_source_code/src/database/migrat /// A central list of all database migrations to be applied. /// -/// New migration classes should be added to this list in the order they are -/// created. The [DatabaseMigrationService] will automatically sort and apply -/// them based on their version. +/// New migration classes should be added to this list. The +/// [DatabaseMigrationService] will automatically sort and apply them based on +/// their `prDate` property. final List allMigrations = [ RefactorAdConfigToRoleBased(), ]; From 1563cd6113e6e7e4ad4aab3b049cf9c181af032f Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 24 Sep 2025 10:26:15 +0100 Subject: [PATCH 11/15] refactor(database): enhance database migration system - Migrate from version-based to PR date-based identification - Update collection name to 'pr_migrations_history' - Modify migration sorting and checking mechanism - Enhance logging and recording of migration details --- .../services/database_migration_service.dart | 65 ++++++++++--------- 1 file changed, 34 insertions(+), 31 deletions(-) diff --git a/lib/src/services/database_migration_service.dart b/lib/src/services/database_migration_service.dart index 2318ea2..6528617 100644 --- a/lib/src/services/database_migration_service.dart +++ b/lib/src/services/database_migration_service.dart @@ -10,7 +10,7 @@ import 'package:mongo_dart/mongo_dart.dart'; /// `migrations_history` collection to ensure idempotency and prevent /// redundant execution. /// -/// Migrations are identified by a unique version string (YYYYMMDDHHMMSS) +/// Migrations are identified by their PR merge date (YYYYMMDDHHMMSS) /// and are always applied in chronological order. /// {@endtemplate} class DatabaseMigrationService { @@ -28,45 +28,47 @@ class DatabaseMigrationService { final List _migrations; /// The name of the MongoDB collection used to track applied migrations. - static const String _migrationsCollectionName = 'migrations_history'; + /// This collection stores metadata about applied Pull Request migrations. + static const String _migrationsCollectionName = 'pr_migrations_history'; /// Initializes the migration service and applies any pending migrations. /// /// This method performs the following steps: - /// 1. Ensures the `migrations_history` collection exists and has a unique - /// index on the `version` field. - /// 2. Fetches all previously applied migration versions from the database. - /// 3. Sorts the registered migrations by their version string. + /// 1. Ensures the `pr_migrations_history` collection exists and has a unique + /// index on the `prDate` field. + /// 2. Fetches all previously applied migration PR dates from the database. + /// 3. Sorts the registered migrations by their `prDate` string. /// 4. Iterates through the sorted migrations, applying only those that /// have not yet been applied. - /// 5. Records each successfully applied migration in the `migrations_history` - /// collection. + /// 5. Records each successfully applied migration's `prDate` and `prId` + /// in the `pr_migrations_history` collection. Future init() async { _log.info('Starting database migration process...'); await _ensureMigrationsCollectionAndIndex(); - final appliedVersions = await _getAppliedMigrationVersions(); - _log.fine('Applied migration versions: $appliedVersions'); + final appliedPrDates = await _getAppliedMigrationPrDates(); + _log.fine('Applied migration PR dates: $appliedPrDates'); - // Sort migrations by version to ensure chronological application. - _migrations.sort((a, b) => a.version.compareTo(b.version)); + // Sort migrations by prDate to ensure chronological application. + _migrations.sort((a, b) => a.prDate.compareTo(b.prDate)); for (final migration in _migrations) { - if (!appliedVersions.contains(migration.version)) { + if (!appliedPrDates.contains(migration.prDate)) { _log.info( - 'Applying migration V${migration.version}: ${migration.description}', + 'Applying migration PR#${migration.prId} (Date: ${migration.prDate}): ' + '${migration.prSummary}', ); try { await migration.up(_db, _log); - await _recordMigration(migration.version); + await _recordMigration(migration.prDate, migration.prId); _log.info( - 'Successfully applied migration V${migration.version}.', + 'Successfully applied migration PR#${migration.prId} (Date: ${migration.prDate}).', ); } catch (e, s) { _log.severe( - 'Failed to apply migration V${migration.version}: ' - '${migration.description}', + 'Failed to apply migration PR#${migration.prId} (Date: ${migration.prDate}): ' + '${migration.prSummary}', e, s, ); @@ -75,7 +77,7 @@ class DatabaseMigrationService { } } else { _log.fine( - 'Migration V${migration.version} already applied. Skipping.', + 'Migration PR#${migration.prId} (Date: ${migration.prDate}) already applied. Skipping.', ); } } @@ -83,35 +85,36 @@ class DatabaseMigrationService { _log.info('Database migration process completed.'); } - /// Ensures the `migrations_history` collection exists and has a unique index - /// on the `version` field. + /// Ensures the `pr_migrations_history` collection exists and has a unique index + /// on the `prDate` field. Future _ensureMigrationsCollectionAndIndex() async { - _log.fine('Ensuring migrations_history collection and index...'); + _log.fine('Ensuring pr_migrations_history collection and index...'); final collection = _db.collection(_migrationsCollectionName); await collection.createIndex( - key: 'version', + key: 'prDate', unique: true, - name: 'version_unique_index', + name: 'prDate_unique_index', ); - _log.fine('Migrations_history collection and index ensured.'); + _log.fine('Pr_migrations_history collection and index ensured.'); } - /// Retrieves a set of versions of all migrations that have already been + /// Retrieves a set of PR dates of all migrations that have already been /// applied to the database. - Future> _getAppliedMigrationVersions() async { + Future> _getAppliedMigrationPrDates() async { final collection = _db.collection(_migrationsCollectionName); final documents = await collection.find().toList(); return documents - .map((doc) => doc['version'] as String) + .map((doc) => doc['prDate'] as String) .toSet(); } - /// Records a successfully applied migration in the `migrations_history` + /// Records a successfully applied migration in the `pr_migrations_history` /// collection. - Future _recordMigration(String version) async { + Future _recordMigration(String prDate, String prId) async { final collection = _db.collection(_migrationsCollectionName); await collection.insertOne({ - 'version': version, + 'prDate': prDate, + 'prId': prId, 'appliedAt': DateTime.now().toUtc(), }); } From 26ea858fcf11443d35a8d7e1b25c548a3a5b01f6 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 24 Sep 2025 10:26:28 +0100 Subject: [PATCH 12/15] docs(README): update database migration descriptions - Change "Versioned Schema Evolution" to "PR-Driven Schema Evolution" - Update migration naming to use Pull Request merge date, summary, and ID - Add traceability with links to originating code changes - Improve clarity and emphasis on saying goodbye to manual updates --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d16f34a..ffb70b5 100644 --- a/README.md +++ b/README.md @@ -99,10 +99,10 @@ Click on any category to explore. --- ### 🔄 Automated Database Migrations -- **Versioned Schema Evolution:** Implements a robust, versioned database migration system that automatically applies schema changes to MongoDB on application startup. +- **PR-Driven Schema Evolution:** Implements a robust, versioned database migration system that automatically applies schema changes to MongoDB on application startup. - **Idempotent & Generic:** Each migration is idempotent and designed to handle schema evolution for *any* model in the database, ensuring data consistency across deployments. -- **Date-Time Based Versioning:** Migrations are named using a `YYYYMMDDHHMMSS__.dart` format, guaranteeing chronological execution and clear context. -> **Your Advantage:** Say goodbye to manual database updates! Your application gracefully handles schema changes, providing a professional and reliable mechanism for evolving your data models without breaking existing data. +- **Traceable Versioning:** Migrations are identified by their Pull Request merge date (`prDate` in `YYYYMMDDHHMMSS` format) for chronological execution, a concise `prSummary`, and a direct `prId` (GitHub PR ID) for full traceability. +> **Your Advantage:** Say goodbye to manual database updates! Your application gracefully handles schema changes, providing a professional and reliable mechanism for evolving your data models without breaking existing data, with clear links to the originating code changes. From 1fb7ad103858ec70722d4acd40dbb9f7304c1679 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 24 Sep 2025 10:29:32 +0100 Subject: [PATCH 13/15] style: format --- lib/src/config/app_dependencies.dart | 5 +++-- lib/src/database/migration.dart | 2 ++ ...250924084800__refactor_ad_config_to_role_based.dart | 2 ++ lib/src/database/migrations/all_migrations.dart | 2 ++ lib/src/registry/model_registry.dart | 4 +++- lib/src/services/database_migration_service.dart | 10 ++++------ 6 files changed, 16 insertions(+), 9 deletions(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index 5af2dec..3de4fdc 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -6,10 +6,10 @@ import 'package:data_repository/data_repository.dart'; import 'package:email_repository/email_repository.dart'; import 'package:email_sendgrid/email_sendgrid.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/config/environment_config.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/database/migrations/all_migrations.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permission_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/auth_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/auth_token_service.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/database/migrations/all_migrations.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/country_query_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/dashboard_summary_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/database_migration_service.dart'; @@ -99,7 +99,8 @@ class AppDependencies { databaseMigrationService = DatabaseMigrationService( db: _mongoDbConnectionManager.db, log: Logger('DatabaseMigrationService'), - migrations: allMigrations, // From lib/src/database/migrations/all_migrations.dart + migrations: + allMigrations, // From lib/src/database/migrations/all_migrations.dart ); await databaseMigrationService.init(); _log.info('Database migrations applied.'); diff --git a/lib/src/database/migration.dart b/lib/src/database/migration.dart index 8b4cb65..2016f3b 100644 --- a/lib/src/database/migration.dart +++ b/lib/src/database/migration.dart @@ -1,3 +1,5 @@ +// ignore_for_file: comment_references + import 'package:logging/logging.dart'; import 'package:mongo_dart/mongo_dart.dart'; diff --git a/lib/src/database/migrations/20250924084800__refactor_ad_config_to_role_based.dart b/lib/src/database/migrations/20250924084800__refactor_ad_config_to_role_based.dart index 1e1353b..6edf33e 100644 --- a/lib/src/database/migrations/20250924084800__refactor_ad_config_to_role_based.dart +++ b/lib/src/database/migrations/20250924084800__refactor_ad_config_to_role_based.dart @@ -1,3 +1,5 @@ +// ignore_for_file: comment_references + import 'package:core/core.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/database/migration.dart'; import 'package:logging/logging.dart'; diff --git a/lib/src/database/migrations/all_migrations.dart b/lib/src/database/migrations/all_migrations.dart index a45edea..4c5a728 100644 --- a/lib/src/database/migrations/all_migrations.dart +++ b/lib/src/database/migrations/all_migrations.dart @@ -1,5 +1,7 @@ import 'package:flutter_news_app_api_server_full_source_code/src/database/migration.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/database/migrations/20250924084800__refactor_ad_config_to_role_based.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/database_migration_service.dart' + show DatabaseMigrationService; /// A central list of all database migrations to be applied. /// diff --git a/lib/src/registry/model_registry.dart b/lib/src/registry/model_registry.dart index d4a3c7b..1e89284 100644 --- a/lib/src/registry/model_registry.dart +++ b/lib/src/registry/model_registry.dart @@ -459,4 +459,6 @@ typedef ModelRegistryMap = Map>; /// This makes the `modelRegistry` map available for injection into the /// request context via `context.read()`. It's primarily /// used by the middleware in `routes/api/v1/data/_middleware.dart`. -final Middleware modelRegistryProvider = provider((_) => modelRegistry); +final Middleware modelRegistryProvider = provider( + (_) => modelRegistry, +); diff --git a/lib/src/services/database_migration_service.dart b/lib/src/services/database_migration_service.dart index 6528617..67aa498 100644 --- a/lib/src/services/database_migration_service.dart +++ b/lib/src/services/database_migration_service.dart @@ -19,9 +19,9 @@ class DatabaseMigrationService { required Db db, required Logger log, required List migrations, - }) : _db = db, - _log = log, - _migrations = migrations; + }) : _db = db, + _log = log, + _migrations = migrations; final Db _db; final Logger _log; @@ -103,9 +103,7 @@ class DatabaseMigrationService { Future> _getAppliedMigrationPrDates() async { final collection = _db.collection(_migrationsCollectionName); final documents = await collection.find().toList(); - return documents - .map((doc) => doc['prDate'] as String) - .toSet(); + return documents.map((doc) => doc['prDate'] as String).toSet(); } /// Records a successfully applied migration in the `pr_migrations_history` From 0dfe286a632ceddf930b72b48463288734fda4ba Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 24 Sep 2025 11:05:15 +0100 Subject: [PATCH 14/15] refactor(migration): update documentation and remove ignored lint - Remove comment_references lint ignore - Update migration documentation to use prDate and prSummary instead of version and description --- lib/src/database/migration.dart | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/src/database/migration.dart b/lib/src/database/migration.dart index 2016f3b..1132bdf 100644 --- a/lib/src/database/migration.dart +++ b/lib/src/database/migration.dart @@ -1,5 +1,3 @@ -// ignore_for_file: comment_references - import 'package:logging/logging.dart'; import 'package:mongo_dart/mongo_dart.dart'; @@ -7,8 +5,8 @@ import 'package:mongo_dart/mongo_dart.dart'; /// An abstract base class for defining database migration scripts. /// /// Each concrete migration must extend this class and implement the [up] and -/// [down] methods. Migrations are identified by a unique [version] string -/// (following the `YYYYMMDDHHMMSS` format) and a [description]. +/// [down] methods. Migrations are identified by a unique [prDate] string +/// (following the `YYYYMMDDHHMMSS` format) and a [prSummary]. /// /// Implementations of [up] and [down] must be **idempotent**, meaning they /// can be safely run multiple times without causing errors or incorrect data. From 249841fc1878772995395950f141772a82c98435 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 24 Sep 2025 11:05:34 +0100 Subject: [PATCH 15/15] fix(database): sort migrations before applying - Create a copy of _migrations list before sorting - Ensures chronological application of migrations based on prDate --- lib/src/services/database_migration_service.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/src/services/database_migration_service.dart b/lib/src/services/database_migration_service.dart index 67aa498..aa4e826 100644 --- a/lib/src/services/database_migration_service.dart +++ b/lib/src/services/database_migration_service.dart @@ -51,9 +51,10 @@ class DatabaseMigrationService { _log.fine('Applied migration PR dates: $appliedPrDates'); // Sort migrations by prDate to ensure chronological application. - _migrations.sort((a, b) => a.prDate.compareTo(b.prDate)); + final sortedMigrations = [..._migrations] + ..sort((a, b) => a.prDate.compareTo(b.prDate)); - for (final migration in _migrations) { + for (final migration in sortedMigrations) { if (!appliedPrDates.contains(migration.prDate)) { _log.info( 'Applying migration PR#${migration.prId} (Date: ${migration.prDate}): '