Example content is for; it reaches the same Seeder. */ ( new Seeder() )->restore(); /** * Fires after the CRM is activated and its tables exist. */ do_action( 'plugixa_crm_activated' ); } /** * Create/upgrade tables and capabilities. Idempotent, so it is safe to run on * activation and again whenever the stored version differs from the shipped one. */ public static function migrate(): bool { $plugin = Plugin::instance(); $modules = $plugin->modules(); // A file-copy update can reach this before boot() has run. if ( ! $modules->all() ) { $modules->discover(); } $failures = $modules->migrate(); // AFTER the modules have migrated, never before. `LegacyCleanup` refuses to drop any name a // module claims, so it has to ask a registry that has already been discovered — and running it // second also means a module is free to adopt one of those tables in future simply by // declaring it, without this line needing to know. LegacyCleanup::run( $modules ); Capabilities::grant( array_merge( array( Capabilities::MANAGE => array( 'administrator' ) ), $modules->capabilities() ) ); if ( array() !== $failures ) { /* * NOT RECORDED AS DONE. The version stays what it was, so the drift guard still sees a * database that is behind and comes back to it — after RETRY_AFTER, not on the next * request. What failed is written down as facts and put into words by * `Admin\MigrationNotice`. */ update_option( self::FAILURE_OPTION, array( 'at' => time(), 'failures' => $failures, ), false ); /** * Fires when a migration did not finish. * * @param array $failures Module id => what went wrong. */ do_action( 'plugixa_crm_migration_failed', $failures ); return false; } delete_option( self::FAILURE_OPTION ); // Recorded only when every module finished. Recording first would mark a half-finished // migration complete; dbDelta is idempotent, so running again costs a few SHOW queries. update_option( self::VERSION_OPTION, PLUGIXA_CRM_VERSION ); update_option( self::MODULES_OPTION, self::fingerprint( $modules ), false ); return true; } /** * The migration that did not finish, if there is one. * * @return array{at: int, failures: array}|null */ public static function failure(): ?array { $recorded = get_option( self::FAILURE_OPTION ); if ( ! is_array( $recorded ) || empty( $recorded['failures'] ) || ! is_array( $recorded['failures'] ) ) { return null; } return array( 'at' => (int) ( $recorded['at'] ?? 0 ), 'failures' => $recorded['failures'], ); } /** * Whether the drift guard should try the migration now. * * Always, unless the last attempt failed less than RETRY_AFTER seconds ago — a migration that * cannot finish must not run every module's dbDelta on every request for as long as the fault * lasts. Activation and the notice's "Try again" do not ask: somebody pressed something. */ public static function due(): bool { $failure = self::failure(); return null === $failure || ( time() - $failure['at'] ) >= self::RETRY_AFTER; } /** * Is the stored schema the one this code and this set of modules expect? * * THE VERSION ALONE IS NOT ENOUGH. Two copies of the plugin can carry the SAME version number and * differ only in which modules are on disk. Replacing the files without an activation hook leaves * the version comparing equal, and `migrate()` never runs: the new modules' tables are missing while * their routes answer 200 with empty data and wpdb logs "Table doesn't exist". Gaining or losing a * module now changes the key, so the next request migrates. * * @param ModuleRegistry $modules The discovered modules. */ public static function schema_is_current( ModuleRegistry $modules ): bool { return get_option( self::VERSION_OPTION ) === PLUGIXA_CRM_VERSION && get_option( self::MODULES_OPTION ) === self::fingerprint( $modules ); } /** * A stable fingerprint of which modules are present. Order-independent, so discovery order cannot * trigger a migration. * * @param ModuleRegistry $modules The discovered modules. */ public static function fingerprint( ModuleRegistry $modules ): string { $ids = array_map( 'strval', array_keys( $modules->all() ) ); sort( $ids ); return md5( implode( ',', $ids ) ); } /** * Register every module's hooks when this request never booted the plugin. * * Activation and uninstall both run in a request where `plugins_loaded` fired before the plugin file was * included, so `Plugin::boot()` did not run and no module listens to anything. `plugixa_crm_loaded` is * the marker: it fires at the end of `boot()`, so a request that booted normally is left alone and no * listener is registered twice (PluginBootTest guards that). */ public static function ensure_module_hooks(): void { if ( self::$hooked || did_action( 'plugixa_crm_loaded' ) ) { return; } self::$hooked = true; $modules = Plugin::instance()->modules(); if ( ! $modules->all() ) { $modules->discover(); } $modules->registerHooks(); } }