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 . '

'; } ); } /** * Emit a §10.1 `configuration_change` audit row describing the * activation-time migration outcome. * * Three branches are supported: * * - Success with `didAdvance() === true`: emit a row with * {@see AuditEventDraft::RESULT_SUCCESS}, target_id = * `"schema:" . $endVersion`, and context carrying the * start / end version pair plus the applied target-version * list so an operator can reconstruct exactly which steps * ran. * * - No-op (`isNoop() === true`): emit nothing. Repeat * activations would otherwise stamp a redundant row on every * `affilun_activate` dispatch even though no schema mutation * occurred — that contradicts the §10.1 "audit-worthy" rule * and would noise up the read surface. * * - Failure (`$error !== null`): emit a row with * {@see AuditEventDraft::RESULT_FAILED}, an error_class / * error_message context, and target_id = * `"schema:" . $startVersion` (the version the site is * stuck at after the failure). The audit row is recorded * BEFORE the surrounding wp_die() / re-raise so a fatal * activation cannot lose the audit trail. * * Audit emission requires both an audit-event repository binding * resolvable through {@see self::resolveAuditEventRepository()} * AND that {@see self::isAuditTableInstalled()} return true (the * `affilun_audit_events` table physically exists). When either * precondition is missing — typical first-activation case where * the migration that creates the table either has not yet run or * just failed mid-pipeline before the audit table itself * landed — the emit is silently skipped per the * {@see RepositoryAuditEventWriter} "tolerate audit emit failure" * contract: losing one activation audit row is preferable to * losing the activation result it described. * * @param MigrationResult|null $result Successful migration * outcome, or null on * failure. * @param Throwable|null $error Migration failure, or * null on success. */ private function emitMigrationAudit( ?MigrationResult $result, ?Throwable $error ): void { if ( $result === null && $error === null ) { return; } if ( $result !== null && $result->isNoop() ) { return; } if ( ! $this->isAuditTableInstalled() ) { return; } try { $events = $this->resolveAuditEventRepository(); } catch ( Throwable $unused ) { unset( $unused ); return; } $writer = new RepositoryAuditEventWriter( $events, new SystemClock() ); try { if ( $error !== null ) { $startVersion = 0; if ( $result !== null ) { $startVersion = $result->startVersion()->toInt(); } $draft = AuditEventDraft::forSystemAction( 'schema.migration', 'configuration_change', 'migrate', AuditEventDraft::RESULT_FAILED, 'Schema migration failed during plugin activation.', 'schema', 'schema:' . $startVersion, array(), array( 'error_class' => get_class( $error ), 'error_message' => self::truncate( $error->getMessage(), 500 ), ) ); } else { /** @var MigrationResult $result */ $startVersion = $result->startVersion()->toInt(); $endVersion = $result->endVersion()->toInt(); $draft = AuditEventDraft::forSystemAction( 'schema.migration', 'configuration_change', 'migrate', AuditEventDraft::RESULT_SUCCESS, sprintf( 'Schema migration advanced from version %d to %d (%d step(s)).', $startVersion, $endVersion, $result->appliedCount() ), 'schema', 'schema:' . $endVersion, array( 'start_version' => $startVersion, 'end_version' => $endVersion, 'applied_versions' => $result->appliedVersions(), ), array( 'applied_count' => $result->appliedCount(), ) ); } $writer->record( $draft ); } catch ( Throwable $unused ) { // Per the AuditEventWriter contract, audit emit failure // must not derail the surrounding business outcome // (activation success / wp_die path). Swallow and // continue so the migration result is what the operator // observes. unset( $unused ); } } /** * Probe whether the `affilun_audit_events` table physically exists * before attempting to emit an audit row. * * Activation is the one moment in the plugin lifecycle when the * audit-events table may legitimately be absent — the migration * that creates it (`CreateAuditEventsTable`, schema v7) is part * of the very pipeline whose outcome we are trying to record. * On a brand-new installation the failure branch could fire * before that migration runs, and even on a successful * advance-from-zero run the writer would race the table's own * creation if we did not gate. * * Returns true when `$wpdb->get_var( "SHOW TABLES LIKE ... )"` * returns the resolved physical table name, false otherwise. * Any driver error during the probe collapses to false so * audit emission silently degrades rather than masking the * activation outcome. */ private function isAuditTableInstalled(): bool { // Pure-unit / in-memory wiring path: there is no `$wpdb` to // probe against, but the in-memory audit repository the // provider was constructed with is fully functional. Treat // the table as installed so unit tests can assert on // emission behavior; the production wp-env smoke path // always carries a real `$wpdb` so this branch is exercised // only in tests. if ( $this->wpdb === null ) { return true; } try { $tableName = $this->resolveTableNames()->resolve( 'affilun_audit_events' ); } catch ( Throwable $unused ) { unset( $unused ); return false; } $wpdb = $this->wpdb; if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'get_var' ) ) { return false; } try { if ( ! method_exists( $wpdb, 'prepare' ) ) { return false; } // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- prepare() invocation immediately below produces the prepared SQL. $sql = $wpdb->prepare( 'SHOW TABLES LIKE %s', $tableName ); if ( ! is_string( $sql ) ) { return false; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- $sql is already prepared via $wpdb->prepare() above. $found = $wpdb->get_var( $sql ); } catch ( Throwable $unused ) { unset( $unused ); return false; } return is_string( $found ) && $found === $tableName; } /** * Truncate a string to at most `$max` bytes, appending an * ellipsis when truncation occurred. * * Used to bound the `error_message` slot on the failure-branch * audit context so a verbose stack-trace-style exception * message cannot bloat the persisted row beyond reasonable * limits. * * @param string $value Source string. * @param int $max Maximum byte length (must be > 3). */ private static function truncate( string $value, int $max ): string { $trimmed = trim( $value ); if ( strlen( $trimmed ) <= $max ) { return $trimmed; } return substr( $trimmed, 0, $max - 3 ) . '...'; } /** * Resolve (and cache) the option store collaborator. */ private function resolveOptionStore(): OptionStore { if ( $this->optionStore === null ) { $this->optionStore = new WpOptionStore(); } return $this->optionStore; } /** * Resolve (and cache) the schema version repository. */ private function resolveSchemaVersionRepository(): SchemaVersionRepository { if ( $this->schemaVersionRepository === null ) { $this->schemaVersionRepository = new SchemaVersionRepository( $this->resolveOptionStore() ); } return $this->schemaVersionRepository; } /** * Resolve (and cache) the schema installer. */ private function resolveSchemaInstaller(): SchemaInstaller { if ( $this->schemaInstaller === null ) { $this->schemaInstaller = new WpdbSchemaInstaller(); } return $this->schemaInstaller; } /** * Resolve (and cache) the table name resolver. * * When neither an explicit resolver nor a `$wpdb` object is * available, this raises a {@see \LogicException} so a * misconfiguration surfaces loudly at register() time rather * than producing a resolver that would emit unprefixed DDL. * * @throws \LogicException When neither an explicit * {@see TableNameResolver} nor a `$wpdb`-like * object was supplied to the constructor. */ private function resolveTableNames(): TableNameResolver { if ( $this->tableNames !== null ) { return $this->tableNames; } if ( $this->wpdb === null ) { $message = 'PersistenceServiceProvider requires either an explicit TableNameResolver ' . 'or a $wpdb-like object to derive the table prefix from.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new \LogicException( $message ); } $this->tableNames = PrefixedTableNameResolver::fromWpdb( $this->wpdb ); return $this->tableNames; } /** * Resolve the charset / collation clause used by every migration. * * Priority: * * 1. Explicit override supplied to the constructor. * 2. `$wpdb->get_charset_collate()` when the injected object * exposes that method. * 3. Empty string, which * {@see \Affilun\Infrastructure\Persistence\Migrations\AbstractCreateTableMigration::buildCreateTableSql()} * resolves to the `DEFAULT CHARSET=utf8mb4` safe default. */ private function resolveCharsetCollate(): string { if ( $this->charsetCollate !== null ) { return $this->charsetCollate; } if ( $this->wpdb !== null && method_exists( $this->wpdb, 'get_charset_collate' ) ) { /** @var mixed $clause */ $clause = $this->wpdb->get_charset_collate(); if ( is_string( $clause ) ) { $this->charsetCollate = $clause; return $this->charsetCollate; } } $this->charsetCollate = ''; return $this->charsetCollate; } /** * Resolve (and cache) the migration runner, populated with every * initial-release migration in canonical ascending order. * * The runner is cached on the instance so that both the registry * binding and the `affilun_activate` handler see the same object — * notably, repeated `run()` calls observe the shared * {@see MigrationRunner::hasRun()} state. */ private function resolveMigrationRunner(): MigrationRunner { if ( $this->migrationRunner !== null ) { return $this->migrationRunner; } $installer = $this->resolveSchemaInstaller(); $tableNames = $this->resolveTableNames(); $charsetCollate = $this->resolveCharsetCollate(); $migrations = array( new CreateProductsTable( $installer, $tableNames, $charsetCollate ), new CreateProductMetaTable( $installer, $tableNames, $charsetCollate ), new CreateEmbedsTable( $installer, $tableNames, $charsetCollate ), new CreateEmbedItemsTable( $installer, $tableNames, $charsetCollate ), new CreateSyncJobsTable( $installer, $tableNames, $charsetCollate ), new CreateLogsTable( $installer, $tableNames, $charsetCollate ), new CreateAuditEventsTable( $installer, $tableNames, $charsetCollate ), new AddProductSearchKeywordsColumns( $installer, $tableNames, $charsetCollate ), new CreateProductUrlsTable( $installer, $tableNames, $charsetCollate ), new CreateClickTables( $installer, $tableNames, $charsetCollate ), new CreateTopAffiliateCandidatesTable( $installer, $tableNames, $charsetCollate ), new CreateAmazonDiscountProductsTable( $installer, $tableNames, $charsetCollate ), ); $this->migrationRunner = ( new MigrationRunner( $this->resolveSchemaVersionRepository() ) ) ->withMigrations( $migrations ); return $this->migrationRunner; } /** * Run the pre-0.7.0 prefix data migration when a real `$wpdb` is available. */ private function runLegacyPrefixDataMigration(): void { $migrator = $this->resolveLegacyPrefixDataMigrator(); if ( $migrator === null ) { return; } $migrator->run(); } /** * Resolve the legacy prefix migrator. */ private function resolveLegacyPrefixDataMigrator(): ?LegacyPrefixDataMigrator { if ( $this->legacyPrefixDataMigrator !== null ) { return $this->legacyPrefixDataMigrator; } if ( $this->wpdb === null ) { return null; } $this->legacyPrefixDataMigrator = new LegacyPrefixDataMigrator( $this->wpdb ); return $this->legacyPrefixDataMigrator; } /** * Resolve (and cache) the product repository. * * When no explicit repository was injected, the provider * constructs a {@see WpdbProductRepository} against the injected * `$wpdb` and the shared {@see TableNameResolver}. A missing * `$wpdb` surfaces as a descriptive {@see \LogicException} so a * misconfiguration (forgetting to forward the WordPress `$wpdb` * to the provider) raises at register() time rather than on the * first repository read. * * @throws \LogicException When no `$wpdb`-like object is available * and no explicit repository was supplied. */ private function resolveProductRepository(): ProductRepository { if ( $this->productRepository !== null ) { return $this->productRepository; } if ( $this->wpdb === null ) { $message = 'PersistenceServiceProvider requires either an explicit ProductRepository ' . 'or a $wpdb-like object to construct a WpdbProductRepository from.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new \LogicException( $message ); } $this->productRepository = new WpdbProductRepository( $this->wpdb, $this->resolveTableNames() ); return $this->productRepository; } /** * Resolve (and cache) the Cloud top affiliate candidate repository. */ private function resolveTopAffiliateCandidateRepository(): TopAffiliateCandidateRepository { if ( $this->topAffiliateCandidateRepository !== null ) { return $this->topAffiliateCandidateRepository; } if ( $this->wpdb === null ) { $this->topAffiliateCandidateRepository = new InMemoryTopAffiliateCandidateRepository(); return $this->topAffiliateCandidateRepository; } $this->topAffiliateCandidateRepository = new WpdbTopAffiliateCandidateRepository( $this->wpdb, $this->resolveTableNames() ); return $this->topAffiliateCandidateRepository; } /** * Resolve (and cache) the Amazon discount repository. */ private function resolveAmazonDiscountRepository(): AmazonDiscountRepository { if ( $this->amazonDiscountRepository !== null ) { return $this->amazonDiscountRepository; } if ( $this->wpdb === null ) { $this->amazonDiscountRepository = new InMemoryAmazonDiscountRepository(); return $this->amazonDiscountRepository; } $this->amazonDiscountRepository = new WpdbAmazonDiscountRepository( $this->wpdb, $this->resolveTableNames() ); return $this->amazonDiscountRepository; } /** * Resolve (and cache) the embed repository. * * When no explicit repository was injected, the provider * constructs a {@see WpdbEmbedRepository} against the injected * `$wpdb` and the shared {@see TableNameResolver}. A missing * `$wpdb` surfaces as a descriptive {@see \LogicException} so a * misconfiguration (forgetting to forward the WordPress `$wpdb` * to the provider) raises at register() time rather than on the * first repository read. * * @throws \LogicException When no `$wpdb`-like object is available * and no explicit repository was supplied. */ private function resolveEmbedRepository(): EmbedRepository { if ( $this->embedRepository !== null ) { return $this->embedRepository; } if ( $this->wpdb === null ) { $message = 'PersistenceServiceProvider requires either an explicit EmbedRepository ' . 'or a $wpdb-like object to construct a WpdbEmbedRepository from.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new \LogicException( $message ); } $this->embedRepository = new WpdbEmbedRepository( $this->wpdb, $this->resolveTableNames() ); return $this->embedRepository; } /** * Resolve (and cache) the embed-item repository. * * When no explicit repository was injected, the provider * constructs a {@see WpdbEmbedItemRepository} against the * injected `$wpdb` and the shared {@see TableNameResolver}. * A missing `$wpdb` surfaces as a descriptive * {@see \LogicException} so a misconfiguration (forgetting to * forward the WordPress `$wpdb` to the provider) raises at * register() time rather than on the first repository read. * * @throws \LogicException When `$wpdb` is null at resolution time * and no explicit repository was supplied. */ private function resolveEmbedItemRepository(): EmbedItemRepository { if ( $this->embedItemRepository !== null ) { return $this->embedItemRepository; } if ( $this->wpdb === null ) { $message = 'PersistenceServiceProvider requires either an explicit EmbedItemRepository ' . 'or a $wpdb-like object to construct a WpdbEmbedItemRepository from.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new \LogicException( $message ); } $this->embedItemRepository = new WpdbEmbedItemRepository( $this->wpdb, $this->resolveTableNames() ); return $this->embedItemRepository; } /** * Resolve (and cache) the audit-event repository. * * When no explicit repository was injected, the provider * constructs a {@see WpdbAuditEventRepository} against the * injected `$wpdb` and the shared {@see TableNameResolver}. * A missing `$wpdb` surfaces as a descriptive * {@see \LogicException} so a misconfiguration (forgetting * to forward the WordPress `$wpdb` to the provider) raises * at register() time rather than on the first repository * read. * * @throws \LogicException When `$wpdb` is null at resolution time * and no explicit repository was supplied. */ private function resolveAuditEventRepository(): AuditEventRepository { if ( $this->auditEventRepository !== null ) { return $this->auditEventRepository; } if ( $this->wpdb === null ) { $message = 'PersistenceServiceProvider requires either an explicit AuditEventRepository ' . 'or a $wpdb-like object to construct a WpdbAuditEventRepository from.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new \LogicException( $message ); } $this->auditEventRepository = new WpdbAuditEventRepository( $this->wpdb, $this->resolveTableNames() ); return $this->auditEventRepository; } /** * Resolve (and cache) the sync-job repository. * * When no explicit repository was injected, the provider * constructs a {@see WpdbSyncJobRepository} against the injected * `$wpdb` and the shared {@see TableNameResolver}. A missing * `$wpdb` surfaces as a descriptive {@see \LogicException} so a * misconfiguration (forgetting to forward the WordPress `$wpdb` * to the provider) raises at register() time rather than on the * first repository read. * * @throws \LogicException When no `$wpdb`-like object is available * and no explicit repository was supplied. */ private function resolveSyncJobRepository(): SyncJobRepository { if ( $this->syncJobRepository !== null ) { return $this->syncJobRepository; } if ( $this->wpdb === null ) { $message = 'PersistenceServiceProvider requires either an explicit SyncJobRepository ' . 'or a $wpdb-like object to construct a WpdbSyncJobRepository from.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new \LogicException( $message ); } $this->syncJobRepository = new WpdbSyncJobRepository( $this->wpdb, $this->resolveTableNames() ); return $this->syncJobRepository; } /** * Resolve (and cache) the log repository. * * When no explicit repository was injected, the provider * constructs a {@see WpdbLogRepository} against the injected * `$wpdb` and the shared {@see TableNameResolver}. A missing * `$wpdb` surfaces as a descriptive {@see \LogicException} so a * misconfiguration (forgetting to forward the WordPress `$wpdb` * to the provider) raises at register() time rather than on the * first repository read. * * @throws \LogicException When no `$wpdb`-like object is available * and no explicit repository was supplied. */ private function resolveLogRepository(): LogRepository { if ( $this->logRepository !== null ) { return $this->logRepository; } if ( $this->wpdb === null ) { $message = 'PersistenceServiceProvider requires either an explicit LogRepository ' . 'or a $wpdb-like object to construct a WpdbLogRepository from.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new \LogicException( $message ); } $this->logRepository = new WpdbLogRepository( $this->wpdb, $this->resolveTableNames() ); return $this->logRepository; } /** * Resolve (and cache) the product-meta repository. * * When no explicit repository was injected, the provider * constructs a {@see WpdbProductMetaRepository} against the * injected `$wpdb` and the shared {@see TableNameResolver}. A * missing `$wpdb` surfaces as a descriptive {@see \LogicException} * so a misconfiguration (forgetting to forward the WordPress * `$wpdb` to the provider) raises at register() time rather than * on the first repository read. * * @throws \LogicException When no `$wpdb`-like object is available * and no explicit repository was supplied. */ private function resolveProductMetaRepository(): ProductMetaRepository { if ( $this->productMetaRepository !== null ) { return $this->productMetaRepository; } if ( $this->wpdb === null ) { $message = 'PersistenceServiceProvider requires either an explicit ProductMetaRepository ' . 'or a $wpdb-like object to construct a WpdbProductMetaRepository from.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new \LogicException( $message ); } $this->productMetaRepository = new WpdbProductMetaRepository( $this->wpdb, $this->resolveTableNames() ); return $this->productMetaRepository; } /** * Resolve (and cache) the product URL repository. * * @throws \LogicException When no `$wpdb`-like object is available * and no explicit repository was supplied. */ private function resolveProductUrlRepository(): ProductUrlRepository { if ( $this->productUrlRepository !== null ) { return $this->productUrlRepository; } if ( $this->wpdb === null ) { $this->productUrlRepository = new InMemoryProductUrlRepository(); return $this->productUrlRepository; } $this->productUrlRepository = new WpdbProductUrlRepository( $this->wpdb, $this->resolveTableNames() ); return $this->productUrlRepository; } /** * Resolve click event repository. */ private function resolveClickEventRepository(): ClickEventRepository { if ( $this->wpdb === null ) { return new InMemoryClickEventRepository(); } return new WpdbClickEventRepository( $this->wpdb, $this->resolveTableNames() ); } /** * Resolve (and cache) the transaction manager. * * When no explicit manager was injected, the provider constructs * a {@see WpdbTransactionManager} against the injected `$wpdb`. * A missing `$wpdb` surfaces as a descriptive * {@see \LogicException} so a misconfiguration (forgetting to * forward the WordPress `$wpdb` to the provider) raises at * register() time rather than on the first transactional() call. * * @throws \LogicException When no `$wpdb`-like object is available * and no explicit manager was supplied. */ private function resolveTransactionManager(): TransactionManager { if ( $this->transactionManager !== null ) { return $this->transactionManager; } if ( $this->wpdb === null ) { $message = 'PersistenceServiceProvider requires either an explicit TransactionManager ' . 'or a $wpdb-like object to construct a WpdbTransactionManager from.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new \LogicException( $message ); } $this->transactionManager = new WpdbTransactionManager( $this->wpdb ); return $this->transactionManager; } }