get_charset_collate()` when * the supplied object exposes that method. When it does not * (unit tests, minimal test beds), the provider falls back to * the empty string, which * {@see \Affilun\Infrastructure\Persistence\Migrations\AbstractCreateTableMigration} * resolves to the `DEFAULT CHARSET=utf8mb4` safe default. * * - Every collaborator is constructed lazily on the first access * so providers that inject pre-built collaborators (for example * integration tests that want to observe migration runs against * a recording schema installer) can override any one of them * via the constructor without having to supply the rest. * * Failure semantics on activation: * * - A successful migration run is silent; the diagnostics surface * is the canonical place to inspect the applied version history. * * - A migration that throws is re-raised through the host's * `wp_die()` (when available) so the activation request aborts * with a visible error message rather than leaving the plugin * active with a half-applied schema. When `wp_die()` is not * available (pure-unit tests, non-activation contexts that * dispatch `affilun_activate` manually), the exception propagates * verbatim so the test harness can assert on it. * * @package Affilun\Infrastructure\Persistence */ // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table-existence checks use prepared SQL or quoted canonical table names. use Affilun\Application\Audit\AuditEventDraft; use Affilun\Application\Audit\RepositoryAuditEventWriter; use Affilun\Application\AmazonDiscount\AmazonDiscountRepository; use Affilun\Bootstrap\HookRegistrar; use Affilun\Bootstrap\Plugin; use Affilun\Bootstrap\ServiceProvider; use Affilun\Bootstrap\ServiceProviderRegistry; use Affilun\Infrastructure\Clock\SystemClock; use Affilun\Infrastructure\Persistence\Migrations\AddProductSearchKeywordsColumns; use Affilun\Infrastructure\Persistence\Migrations\CreateAuditEventsTable; use Affilun\Infrastructure\Persistence\Migrations\CreateAmazonDiscountProductsTable; use Affilun\Infrastructure\Persistence\Migrations\CreateClickTables; use Affilun\Infrastructure\Persistence\Migrations\CreateEmbedItemsTable; use Affilun\Infrastructure\Persistence\Migrations\CreateEmbedsTable; use Affilun\Infrastructure\Persistence\Migrations\CreateLogsTable; use Affilun\Infrastructure\Persistence\Migrations\CreateProductMetaTable; use Affilun\Infrastructure\Persistence\Migrations\CreateProductsTable; use Affilun\Infrastructure\Persistence\Migrations\CreateProductUrlsTable; use Affilun\Infrastructure\Persistence\Migrations\CreateSyncJobsTable; use Affilun\Infrastructure\Persistence\Migrations\CreateTopAffiliateCandidatesTable; use Throwable; /** * Binds the persistence-layer collaborators and wires the migration * runner to the `affilun_activate` action fired by * {@see Plugin::onActivate()}. * * See the file-level docblock for the lifecycle, resolution policy, * and activation-time failure semantics. */ final class PersistenceServiceProvider implements ServiceProvider { /** * Registry key under which the {@see OptionStore} collaborator is * bound. * * Consumers that need raw key/value access to the WordPress options * table (outside the narrow schema-version case handled by the * dedicated repository binding) should resolve this binding rather * than instantiating {@see WpOptionStore} directly, so tests can * swap in an {@see InMemoryOptionStore} by rebinding the same key. */ public const BINDING_OPTION_STORE = 'persistence.option_store'; /** * Registry key under which the {@see SchemaVersionRepository} is * bound. */ public const BINDING_SCHEMA_VERSION_REPOSITORY = 'persistence.schema_version_repository'; /** * Registry key under which the {@see TableNameResolver} is bound. */ public const BINDING_TABLE_NAME_RESOLVER = 'persistence.table_name_resolver'; /** * Registry key under which the {@see SchemaInstaller} is bound. */ public const BINDING_SCHEMA_INSTALLER = 'persistence.schema_installer'; /** * Registry key under which the fully-populated {@see MigrationRunner} * is bound. * * The runner returned by this binding has every initial-release * migration already registered, so callers can invoke * {@see MigrationRunner::run()} directly without re-registering * the pipeline. */ public const BINDING_MIGRATION_RUNNER = 'persistence.migration_runner'; /** * Registry key under which the {@see ProductRepository} is bound. * * Production wiring returns a {@see WpdbProductRepository} derived * from the injected `$wpdb` and the shared * {@see TableNameResolver}; tests that want to exercise the * activation path without a live database can inject an * {@see InMemoryProductRepository} via the constructor override. */ public const BINDING_PRODUCT_REPOSITORY = 'persistence.product_repository'; /** * Registry key under which the {@see TopAffiliateCandidateRepository} is bound. */ public const BINDING_TOP_AFFILIATE_CANDIDATE_REPOSITORY = 'persistence.top_affiliate_candidate_repository'; /** * Registry key for daily Amazon discount snapshots. */ public const BINDING_AMAZON_DISCOUNT_REPOSITORY = 'persistence.amazon_discount_repository'; /** * Registry key under which the {@see EmbedRepository} is bound. * * Production wiring returns a {@see WpdbEmbedRepository} derived * from the injected `$wpdb` and the shared * {@see TableNameResolver}; tests that want to exercise the * activation path without a live database can inject an * {@see InMemoryEmbedRepository} via the constructor override. */ public const BINDING_EMBED_REPOSITORY = 'persistence.embed_repository'; /** * Registry key under which the {@see EmbedItemRepository} is bound. * * Phase 2 KI-002 addition. Production wiring returns a * {@see WpdbEmbedItemRepository} derived from the injected * `$wpdb` and the shared {@see TableNameResolver}; tests that * want to exercise the activation path without a live database * can inject an {@see InMemoryEmbedItemRepository} via the * constructor override. */ public const BINDING_EMBED_ITEM_REPOSITORY = 'persistence.embed_item_repository'; /** * Registry key under which the {@see AuditEventRepository} is * bound. * * Phase 2 KI-006 addition. Production wiring returns a * {@see WpdbAuditEventRepository} derived from the injected * `$wpdb` and the shared {@see TableNameResolver}; tests that * want to exercise the activation path without a live database * can inject an {@see InMemoryAuditEventRepository} via the * constructor override. */ public const BINDING_AUDIT_EVENT_REPOSITORY = 'persistence.audit_event_repository'; /** * Registry key under which the {@see SyncJobRepository} is bound. * * Production wiring returns a {@see WpdbSyncJobRepository} derived * from the injected `$wpdb` and the shared * {@see TableNameResolver}; tests that want to exercise the * activation path without a live database can inject an * {@see InMemorySyncJobRepository} via the constructor override. */ public const BINDING_SYNC_JOB_REPOSITORY = 'persistence.sync_job_repository'; /** * Registry key under which the {@see LogRepository} is bound. * * Production wiring returns a {@see WpdbLogRepository} derived * from the injected `$wpdb` and the shared * {@see TableNameResolver}; tests that want to exercise the * activation path without a live database can inject an * {@see InMemoryLogRepository} via the constructor override. */ public const BINDING_LOG_REPOSITORY = 'persistence.log_repository'; /** * Registry key under which the {@see ProductMetaRepository} is bound. * * Production wiring returns a {@see WpdbProductMetaRepository} * derived from the injected `$wpdb` and the shared * {@see TableNameResolver}; tests that want to exercise the * activation path without a live database can inject an * {@see InMemoryProductMetaRepository} via the constructor * override. */ public const BINDING_PRODUCT_META_REPOSITORY = 'persistence.product_meta_repository'; /** * Registry key under which the {@see ProductUrlRepository} is bound. */ public const BINDING_PRODUCT_URL_REPOSITORY = 'persistence.product_url_repository'; /** * Registry key under which the {@see ClickEventRepository} is bound. */ public const BINDING_CLICK_EVENT_REPOSITORY = 'persistence.click_event_repository'; /** * Registry key under which the {@see TransactionManager} is bound. * * Production wiring returns a {@see WpdbTransactionManager} that * brackets units of work with the WordPress `$wpdb` connection's * transaction primitives; tests that want to exercise the * boundary without a live database can inject an * {@see InMemoryTransactionManager} via the constructor override. */ public const BINDING_TRANSACTION_MANAGER = 'persistence.transaction_manager'; /** * WordPress `$wpdb`-like object used to resolve the table prefix * and the charset/collation clause, or null when the provider was * constructed without one. * * Typed as `object` rather than the concrete `\wpdb` class so this * file stays trivially loadable in pure-unit tests that run * outside a WordPress environment. */ private ?object $wpdb; /** * Option store used to back the schema version repository. * * Injected for test reuse; null triggers construction of a * {@see WpOptionStore} on first access. */ private ?OptionStore $optionStore; /** * Schema installer each migration forwards its DDL to. * * Injected for test reuse; null triggers construction of a * {@see WpdbSchemaInstaller} on first access. */ private ?SchemaInstaller $schemaInstaller; /** * Table name resolver shared by every migration registered on the * runner. * * Injected for test reuse; null triggers construction of a * {@see PrefixedTableNameResolver} derived from the injected * `$wpdb` prefix on first access. */ private ?TableNameResolver $tableNames; /** * Charset/collation clause override. * * When null, the provider asks `$wpdb->get_charset_collate()` at * resolution time (and falls back to the empty string when that * method is not available). Tests and diagnostics that need a * deterministic clause may supply an explicit string here. */ private ?string $charsetCollate; /** * Product repository override. * * When null, the provider constructs a {@see WpdbProductRepository} * on first access using the injected `$wpdb` and the shared * {@see TableNameResolver}. Tests may inject an * {@see InMemoryProductRepository} (or any other * {@see ProductRepository}) to exercise consumers without a live * database. */ private ?ProductRepository $productRepository; /** * Cloud top affiliate candidate repository override. */ private ?TopAffiliateCandidateRepository $topAffiliateCandidateRepository; /** * Amazon discount repository override. */ private ?AmazonDiscountRepository $amazonDiscountRepository; /** * Embed repository override. * * When null, the provider constructs a {@see WpdbEmbedRepository} * on first access using the injected `$wpdb` and the shared * {@see TableNameResolver}. Tests may inject an * {@see InMemoryEmbedRepository} (or any other * {@see EmbedRepository}) to exercise consumers without a live * database. */ private ?EmbedRepository $embedRepository; /** * Embed-item repository override. * * When null, the provider constructs a * {@see WpdbEmbedItemRepository} on first access using the * injected `$wpdb` and the shared {@see TableNameResolver}. * Tests may inject an {@see InMemoryEmbedItemRepository} (or * any other {@see EmbedItemRepository}) to exercise consumers * without a live database. */ private ?EmbedItemRepository $embedItemRepository; /** * Audit-event repository override. * * When null, the provider constructs a * {@see WpdbAuditEventRepository} on first access using the * injected `$wpdb` and the shared {@see TableNameResolver}. * Tests may inject an {@see InMemoryAuditEventRepository} * (or any other {@see AuditEventRepository}) to exercise * consumers without a live database. */ private ?AuditEventRepository $auditEventRepository; /** * Sync-job repository override. * * When null, the provider constructs a {@see WpdbSyncJobRepository} * on first access using the injected `$wpdb` and the shared * {@see TableNameResolver}. Tests may inject an * {@see InMemorySyncJobRepository} (or any other * {@see SyncJobRepository}) to exercise consumers without a live * database. */ private ?SyncJobRepository $syncJobRepository; /** * Log repository override. * * When null, the provider constructs a {@see WpdbLogRepository} * on first access using the injected `$wpdb` and the shared * {@see TableNameResolver}. Tests may inject an * {@see InMemoryLogRepository} (or any other * {@see LogRepository}) to exercise consumers without a live * database. */ private ?LogRepository $logRepository; /** * Product-meta repository override. * * When null, the provider constructs a * {@see WpdbProductMetaRepository} on first access using the * injected `$wpdb` and the shared {@see TableNameResolver}. Tests * may inject an {@see InMemoryProductMetaRepository} (or any * other {@see ProductMetaRepository}) to exercise consumers * without a live database. */ private ?ProductMetaRepository $productMetaRepository; /** * Product URL repository override. */ private ?ProductUrlRepository $productUrlRepository; /** * Transaction manager override. * * When null, the provider constructs a * {@see WpdbTransactionManager} on first access using the * injected `$wpdb`. Tests may inject an * {@see InMemoryTransactionManager} (or any other * {@see TransactionManager}) to exercise consumers without a * live database. */ private ?TransactionManager $transactionManager; /** * Cached schema version repository returned by the registry binding. */ private ?SchemaVersionRepository $schemaVersionRepository = null; /** * Cached migration runner returned by the registry binding. * * Populated lazily in {@see self::resolveMigrationRunner()} so that * both the registry binding and the activation handler see the * same runner instance. */ private ?MigrationRunner $migrationRunner = null; /** * Cached one-shot migrator for pre-0.7.0 `afl_*` storage. */ private ?LegacyPrefixDataMigrator $legacyPrefixDataMigrator = null; /** * Construct the provider. * * Every collaborator is optional; omitting one causes the provider * to build the production default on first access. This shape * keeps the production wiring ergonomic * (`new PersistenceServiceProvider( $wpdb )`) while still allowing * integration tests to inject synthetic collaborators one axis at * a time. * * @param object|null $wpdb WordPress `$wpdb`-like * object used to derive * the table prefix and * the charset/collation * clause. Required * when no explicit * {@see TableNameResolver} * is injected. * @param OptionStore|null $optionStore Explicit option store. * Defaults to a * {@see WpOptionStore}. * @param SchemaInstaller|null $schemaInstaller Explicit installer. * Defaults to a * {@see WpdbSchemaInstaller}. * @param TableNameResolver|null $tableNames Explicit resolver. * Defaults to * {@see PrefixedTableNameResolver::fromWpdb()}. * @param string|null $charsetCollate Explicit charset / * collation clause. * Defaults to * `$wpdb->get_charset_collate()` * when available. * @param ProductRepository|null $productRepository Explicit product * repository. Defaults * to a * {@see WpdbProductRepository} * wired against the * injected `$wpdb`. * @param EmbedRepository|null $embedRepository Explicit embed * repository. Defaults * to a * {@see WpdbEmbedRepository} * wired against the * injected `$wpdb`. * @param SyncJobRepository|null $syncJobRepository Explicit sync-job * repository. Defaults * to a * {@see WpdbSyncJobRepository} * wired against the * injected `$wpdb`. * @param LogRepository|null $logRepository Explicit log * repository. Defaults * to a * {@see WpdbLogRepository} * wired against the * injected `$wpdb`. * @param ProductMetaRepository|null $productMetaRepository Explicit * product-meta * repository. * Defaults to a * {@see WpdbProductMetaRepository} * wired against * the injected * `$wpdb`. * @param TransactionManager|null $transactionManager Explicit * transaction * manager. * Defaults to a * {@see WpdbTransactionManager} * wired against * the injected * `$wpdb`. * @param EmbedItemRepository|null $embedItemRepository Explicit * embed-item * repository * (Phase 2 KI-002). * Defaults to a * {@see WpdbEmbedItemRepository} * wired against * the injected * `$wpdb`. * @param AuditEventRepository|null $auditEventRepository Explicit * audit-event * repository * (Phase 2 KI-006). * Defaults to a * {@see WpdbAuditEventRepository} * wired against * the injected * `$wpdb`. * @param ProductUrlRepository|null $productUrlRepository Explicit product URL repository. * @param TopAffiliateCandidateRepository|null $topAffiliateCandidateRepository Explicit Cloud candidate repository. */ public function __construct( ?object $wpdb = null, ?OptionStore $optionStore = null, ?SchemaInstaller $schemaInstaller = null, ?TableNameResolver $tableNames = null, ?string $charsetCollate = null, ?ProductRepository $productRepository = null, ?EmbedRepository $embedRepository = null, ?SyncJobRepository $syncJobRepository = null, ?LogRepository $logRepository = null, ?ProductMetaRepository $productMetaRepository = null, ?TransactionManager $transactionManager = null, ?EmbedItemRepository $embedItemRepository = null, ?AuditEventRepository $auditEventRepository = null, ?ProductUrlRepository $productUrlRepository = null, ?TopAffiliateCandidateRepository $topAffiliateCandidateRepository = null, ?AmazonDiscountRepository $amazonDiscountRepository = null ) { $this->wpdb = $wpdb; $this->optionStore = $optionStore; $this->schemaInstaller = $schemaInstaller; $this->embedItemRepository = $embedItemRepository; $this->auditEventRepository = $auditEventRepository; $this->tableNames = $tableNames; $this->charsetCollate = $charsetCollate; $this->productRepository = $productRepository; $this->topAffiliateCandidateRepository = $topAffiliateCandidateRepository; $this->amazonDiscountRepository = $amazonDiscountRepository; $this->embedRepository = $embedRepository; $this->syncJobRepository = $syncJobRepository; $this->logRepository = $logRepository; $this->productMetaRepository = $productMetaRepository; $this->productUrlRepository = $productUrlRepository; $this->transactionManager = $transactionManager; } /** * Bind every persistence collaborator into the registry. * * Resolution is deliberately eager at this point so that a * misconfiguration (for example a missing `$wpdb` when no * {@see TableNameResolver} was injected) surfaces during * {@see ServiceProviderRegistry::registerAll()} rather than on * the first activation attempt. * * @param ServiceProviderRegistry $registry Registry driving this * bootstrap pass. */ public function register( ServiceProviderRegistry $registry ): void { $registry->bind( self::BINDING_OPTION_STORE, $this->resolveOptionStore() ); $registry->bind( self::BINDING_SCHEMA_VERSION_REPOSITORY, $this->resolveSchemaVersionRepository() ); $registry->bind( self::BINDING_TABLE_NAME_RESOLVER, $this->resolveTableNames() ); $registry->bind( self::BINDING_SCHEMA_INSTALLER, $this->resolveSchemaInstaller() ); $registry->bind( self::BINDING_MIGRATION_RUNNER, $this->resolveMigrationRunner() ); $registry->bind( self::BINDING_PRODUCT_REPOSITORY, $this->resolveProductRepository() ); $registry->bind( self::BINDING_TOP_AFFILIATE_CANDIDATE_REPOSITORY, $this->resolveTopAffiliateCandidateRepository() ); $registry->bind( self::BINDING_AMAZON_DISCOUNT_REPOSITORY, $this->resolveAmazonDiscountRepository() ); $registry->bind( self::BINDING_EMBED_REPOSITORY, $this->resolveEmbedRepository() ); $registry->bind( self::BINDING_EMBED_ITEM_REPOSITORY, $this->resolveEmbedItemRepository() ); $registry->bind( self::BINDING_AUDIT_EVENT_REPOSITORY, $this->resolveAuditEventRepository() ); $registry->bind( self::BINDING_SYNC_JOB_REPOSITORY, $this->resolveSyncJobRepository() ); $registry->bind( self::BINDING_LOG_REPOSITORY, $this->resolveLogRepository() ); $registry->bind( self::BINDING_PRODUCT_META_REPOSITORY, $this->resolveProductMetaRepository() ); $registry->bind( self::BINDING_PRODUCT_URL_REPOSITORY, $this->resolveProductUrlRepository() ); $registry->bind( self::BINDING_CLICK_EVENT_REPOSITORY, $this->resolveClickEventRepository() ); $registry->bind( self::BINDING_TRANSACTION_MANAGER, $this->resolveTransactionManager() ); } /** * Subscribe the migration runner to the `affilun_activate` action. * * A single action handler is registered at the default priority; * it forwards to {@see self::handleActivate()} which owns the * failure path. Registering via the {@see HookRegistrar} keeps * the wiring inspectable by tests and by the diagnostics surface. * * @param HookRegistrar $hooks Shared hook registrar. */ public function boot( HookRegistrar $hooks ): void { $hooks->addAction( 'affilun_activate', function ( $plugin = null ): void { $this->handleActivate( $plugin instanceof Plugin ? $plugin : null ); } ); $hooks->addAction( 'admin_init', array( $this, 'onRuntimeSchemaMaintenance' ), 10, 0 ); $hooks->addAction( 'rest_api_init', array( $this, 'onRuntimeSchemaMaintenance' ), 1, 0 ); } /** * Run pending schema migrations during normal admin / REST requests. * * This covers plugin update flows where WordPress replaces the * plugin files without firing the activation hook. The handler is * intentionally lightweight on up-to-date sites: it reads the stored * schema version, compares it to the highest registered migration, * and returns without issuing DDL when no migration is pending. */ public function onRuntimeSchemaMaintenance(): void { try { $runner = $this->resolveMigrationRunner(); $current = $this->resolveSchemaVersionRepository()->currentVersion(); $target = $runner->highestKnownVersion(); if ( $current->toInt() < $target->toInt() ) { $result = $runner->run(); $this->emitMigrationAudit( $result, null ); } $this->runLegacyPrefixDataMigration(); } catch ( Throwable $error ) { $this->emitMigrationAudit( null, $error ); $this->reportRuntimeMigrationFailure( $error ); } } /** * Migration runner bound into the registry, populated with every * initial-release migration. * * Exposed so integration tests and diagnostics surfaces can run or * introspect the pipeline without having to resolve the binding * through the registry. */ public function migrationRunner(): MigrationRunner { return $this->resolveMigrationRunner(); } /** * Product repository bound into the registry. * * Exposed so the activation flow and diagnostics surfaces can * reach the same repository instance that consumers resolve * through {@see self::BINDING_PRODUCT_REPOSITORY}. */ public function productRepository(): ProductRepository { return $this->resolveProductRepository(); } /** * Embed repository bound into the registry. * * Exposed so the activation flow and diagnostics surfaces can * reach the same repository instance that consumers resolve * through {@see self::BINDING_EMBED_REPOSITORY}. */ public function embedRepository(): EmbedRepository { return $this->resolveEmbedRepository(); } /** * Embed-item repository bound into the registry. * * Exposed so the activation flow and diagnostics surfaces can * reach the same repository instance that consumers resolve * through {@see self::BINDING_EMBED_ITEM_REPOSITORY}. */ public function embedItemRepository(): EmbedItemRepository { return $this->resolveEmbedItemRepository(); } /** * Audit-event repository bound into the registry. * * Exposed so the activation flow and diagnostics surfaces can * reach the same repository instance that consumers resolve * through {@see self::BINDING_AUDIT_EVENT_REPOSITORY}. */ public function auditEventRepository(): AuditEventRepository { return $this->resolveAuditEventRepository(); } /** * Sync-job repository bound into the registry. * * Exposed so the activation flow and diagnostics surfaces can * reach the same repository instance that consumers resolve * through {@see self::BINDING_SYNC_JOB_REPOSITORY}. */ public function syncJobRepository(): SyncJobRepository { return $this->resolveSyncJobRepository(); } /** * Log repository bound into the registry. * * Exposed so the activation flow and diagnostics surfaces can * reach the same repository instance that consumers resolve * through {@see self::BINDING_LOG_REPOSITORY}. */ public function logRepository(): LogRepository { return $this->resolveLogRepository(); } /** * Product-meta repository bound into the registry. * * Exposed so the activation flow and diagnostics surfaces can * reach the same repository instance that consumers resolve * through {@see self::BINDING_PRODUCT_META_REPOSITORY}. */ public function productMetaRepository(): ProductMetaRepository { return $this->resolveProductMetaRepository(); } /** * Product URL repository bound into the registry. */ public function productUrlRepository(): ProductUrlRepository { return $this->resolveProductUrlRepository(); } /** * Transaction manager bound into the registry. * * Exposed so the activation flow and diagnostics surfaces can * reach the same manager instance that consumers resolve through * {@see self::BINDING_TRANSACTION_MANAGER}. Sharing a single * instance is required because the manager owns the * `inTransaction` flag on itself rather than on `$wpdb`; see * {@see WpdbTransactionManager} for the rationale. */ public function transactionManager(): TransactionManager { return $this->resolveTransactionManager(); } /** * Schema version repository bound into the registry. * * Exposed for the same reason as {@see self::migrationRunner()} — * diagnostics surfaces frequently want to report the current * schema version without re-deriving the repository. */ public function schemaVersionRepository(): SchemaVersionRepository { return $this->resolveSchemaVersionRepository(); } /** * Run the migration pipeline in response to `affilun_activate`. * * The handler deliberately swallows no exception: on a thrown * migration we route through `wp_die()` (when available) so the * activation request aborts with a visible error rather than * leaving the plugin active with a half-applied schema. When * `wp_die()` is not available — pure-unit tests that dispatch * `affilun_activate` manually without the WordPress stub — the * exception propagates verbatim so the test harness can assert * on it. * * The `$plugin` argument is accepted for signature parity with * the action dispatched by {@see Plugin::onActivate()} but is not * currently consumed; it is kept here so future activation-time * work (for example capability grants) can reach the plugin * instance without a signature change. * * @param Plugin|null $plugin Plugin instance forwarded by the * `affilun_activate` action, or null when * the handler is invoked outside the * normal activation path. * * @throws Throwable Re-thrown from the failing migration when * `wp_die()` is not available (pure-unit test * context). */ private function handleActivate( ?Plugin $plugin = null ): void { unset( $plugin ); $result = null; try { $result = $this->resolveMigrationRunner()->run(); $this->runLegacyPrefixDataMigration(); } catch ( Throwable $error ) { $this->emitMigrationAudit( null, $error ); if ( ! function_exists( 'wp_die' ) ) { throw $error; } $message = sprintf( 'affilun: schema migration failed during activation: %s', $error->getMessage() ); $title = function_exists( 'esc_html__' ) ? (string) esc_html__( 'affilun の有効化を中止しました', 'affilun' ) : 'affilun の有効化を中止しました'; wp_die( // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped above via esc_html() when available. function_exists( 'esc_html' ) ? (string) esc_html( $message ) : $message, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped above via esc_html__() when available. $title, array( 'back_link' => true ) ); } if ( $result !== null ) { $this->emitMigrationAudit( $result, null ); } } /** * Report a runtime migration failure without exposing sensitive data. * * @param Throwable $error Migration failure. */ private function reportRuntimeMigrationFailure( Throwable $error ): void { $message = sprintf( 'affilun: schema migration failed during runtime maintenance: %s', self::truncate( $error->getMessage(), 500 ) ); if ( function_exists( 'error_log' ) ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Runtime migration failures require operator visibility. error_log( $message ); } if ( ! function_exists( 'add_action' ) ) { return; } add_action( 'admin_notices', static function () use ( $message ): void { if ( function_exists( 'current_user_can' ) && ! current_user_can( 'manage_options' ) ) { return; } $escaped = function_exists( 'esc_html' ) ? (string) esc_html( $message ) : htmlspecialchars( $message, ENT_QUOTES, 'UTF-8' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped immediately above. echo '
' . $escaped . '