diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a0fbaff..2de2717 100755 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -356,6 +356,22 @@ $psa_settings = array( ); ``` +### Schema versioning & automatic upgrades + +The plugin stores the schema version it last migrated to in a separate option, `psa_db_version`. This is distinct from `PSA_VERSION` (the running code's semver), and lets the plugin detect whether the database needs to catch up after an in-place file update. + +- **Option name:** `PSA_Upgrade::OPTION_NAME` (`psa_db_version`), string, semver. +- **Default:** `PSA_Upgrade::DEFAULT_STORED_VERSION` (`'0.0.0'`) when the option has never been written — strictly less than any released version so fresh installs always trigger the upgrade path. +- **Driver:** `PSA_Upgrade::maybe_run()` in `includes/class-psa-upgrade.php`, called on every `admin_init` from `psa_admin_init()`. The method: + 1. Reads `psa_db_version`. + 2. Calls the pure helper `PSA_Upgrade::is_needed( $stored, PSA_VERSION )`, a thin wrapper over `version_compare` (stored < current → upgrade). + 3. If an upgrade is needed, re-runs `PSA_Cost_Tracker::create_table()` so `dbDelta` picks up any additive schema changes (new columns, new indexes). + 4. Writes `update_option( 'psa_db_version', PSA_VERSION, false )` so the upgrade doesn't re-run on the next request. +- **Why on `admin_init`:** dbDelta is safe-to-repeat but not free. Running it once when an admin visits after an update — rather than on every frontend page load — keeps the cost near zero without requiring users to deactivate/reactivate the plugin. +- **Downgrade / rollback:** if the running code is older than `psa_db_version` (e.g., deploy rolled back), `is_needed()` returns false and the driver is a no-op. The newer schema is left intact; old code continues to work against it as long as changes are additive. +- **Cleanup:** `uninstall.php` deletes `psa_db_version` alongside the other plugin options. +- **Testability:** `PSA_Upgrade::is_needed()` is a pure static method (no WordPress API calls) and is covered by `tests/unit/UpgradeTest.php` across fresh-install, same-version, future-version, and older-version scenarios, plus constant-stability checks on `OPTION_NAME` and `DEFAULT_STORED_VERSION`. + ## Frontend Architecture ### JavaScript (jQuery-based) diff --git a/includes/class-psa-upgrade.php b/includes/class-psa-upgrade.php new file mode 100644 index 0000000..643fa2b --- /dev/null +++ b/includes/class-psa-upgrade.php @@ -0,0 +1,83 @@ +=' ) ) { - return; - } - - // Re-run CREATE TABLE via dbDelta — additive schema changes (new columns) - // will be applied automatically. - PSA_Cost_Tracker::create_table(); - - update_option( 'psa_db_version', PSA_VERSION, false ); -} - /** * Check if current page is the Echo KB main page. * Uses is_page() which is reliable even in block templates. diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 34f8ad5..8dc8f02 100755 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -130,3 +130,4 @@ require_once ABSPATH . 'includes/class-psa-cost-tracker.php'; require_once ABSPATH . 'includes/class-psa-openrouter.php'; require_once ABSPATH . 'includes/class-psa-kb-builder.php'; require_once ABSPATH . 'includes/class-psa-ai-generator.php'; +require_once ABSPATH . 'includes/class-psa-upgrade.php'; diff --git a/tests/unit/UpgradeTest.php b/tests/unit/UpgradeTest.php new file mode 100644 index 0000000..999773a --- /dev/null +++ b/tests/unit/UpgradeTest.php @@ -0,0 +1,87 @@ +assertTrue( + PSA_Upgrade::is_needed( '0.0.0', '4.4.3' ), + 'Fresh install (stored = 0.0.0) must trigger an upgrade against any released version.' + ); + } + + /** + * Already-upgraded: stored version equals current. No upgrade needed. + */ + public function test_same_version_skips_upgrade(): void { + $this->assertFalse( + PSA_Upgrade::is_needed( '4.4.3', '4.4.3' ), + 'Stored version equal to current version must not trigger an upgrade.' + ); + } + + /** + * Future-version: stored version is ahead of the running code (e.g., a + * downgrade, or a user running multiple sites out of sync). Do not run + * upgrade — it could apply an older schema over a newer one. + */ + public function test_future_stored_version_skips_upgrade(): void { + $this->assertFalse( + PSA_Upgrade::is_needed( '5.0.0', '4.4.3' ), + 'Stored version greater than current version must not trigger an upgrade.' + ); + } + + /** + * Belt-and-braces: a strictly newer code version against an older + * stored version must trigger an upgrade. Guards against an accidental + * operator flip in version_compare() (e.g., '>=' vs '<'). + */ + public function test_older_stored_version_triggers_upgrade(): void { + $this->assertTrue( + PSA_Upgrade::is_needed( '4.4.2', '4.4.3' ), + 'Stored version older than current version must trigger an upgrade.' + ); + } + + /** + * Constants used by the driver should be stable across releases so + * existing installs keep pointing at the same option key. + */ + public function test_option_name_constant_is_stable(): void { + $this->assertSame( 'psa_db_version', PSA_Upgrade::OPTION_NAME ); + } + + /** + * Default stored version must be strictly less than any future release + * so fresh-install detection keeps working. + */ + public function test_default_stored_version_is_zero(): void { + $this->assertSame( '0.0.0', PSA_Upgrade::DEFAULT_STORED_VERSION ); + } +} diff --git a/uninstall.php b/uninstall.php index 6381564..8b81cd9 100755 --- a/uninstall.php +++ b/uninstall.php @@ -42,6 +42,7 @@ if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) { // 3. Delete plugin options. delete_option( 'psa_settings' ); delete_option( 'psa_search_cache_gen' ); +delete_option( 'psa_db_version' ); // 4. Delete all plugin transients (validation cache, rate limits). $wpdb->query(