diff --git a/src/Core/Module/ModuleManager.php b/src/Core/Module/ModuleManager.php index 8a6d64a4..6d5aca05 100644 --- a/src/Core/Module/ModuleManager.php +++ b/src/Core/Module/ModuleManager.php @@ -129,8 +129,11 @@ class ModuleManager { * already-installed modules are skipped, and a module's up-migrations use * CREATE TABLE IF NOT EXISTS so re-provisioning an existing panel is safe. * - * Modules are installed in dependency order; admin-disabled or failed - * modules are left untouched. + * Modules are installed in dependency order. Only admin-disabled modules are + * left untouched — a module whose previous install FAILED (or crashed mid-way, + * leaving it Installing) is retried here, so re-running `console.php status` + * self-heals once the root cause is fixed. Retrying is safe: the master schema + * uses CREATE TABLE IF NOT EXISTS. * * @return string[] Names of modules that were installed this pass. */ @@ -143,16 +146,15 @@ class ModuleManager { } } - // Candidates: present on disk, not yet installed, not admin-disabled/failed. + // Candidates: present on disk, not yet installed, not admin-disabled. + // Failed/Installing (a never-completed install) are retried, not skipped. $pending = []; foreach ($modules as $module) { $name = $module['name']; if (isset($installed[$name])) { continue; } - $state = $module['state'] ?? null; - if ($state === \XcVm\Core\Enum\ModuleState::Disabled - || $state === \XcVm\Core\Enum\ModuleState::Failed) { + if (($module['state'] ?? null) === \XcVm\Core\Enum\ModuleState::Disabled) { continue; } $pending[$name] = $module; @@ -289,20 +291,17 @@ class ModuleManager { try { $db = $this->getDb() ?? \XcVm\Infrastructure\Database\DatabaseFactory::get(); - // Apply every up-migration up to the target version, then the - // module's own install() hook for any non-SQL setup. - $run = function () use ($module, $modulePath, $db, $targetVersion) { - if ($db !== null) { - ModuleMigrator::up($modulePath, $db, null, (string) $targetVersion); - } - $module->install(); - }; - if ($db !== null && method_exists($db, 'transactional')) { - // Wrap install in a DB transaction so partial migrations are rolled back. - $db->transactional($run); - } else { - $run(); + // Apply the module's master schema, then its own install() hook for any + // non-SQL setup. NB: schema files are DDL (CREATE/ALTER), which + // MySQL/MariaDB implicitly commit — a wrapping transaction gives no + // rollback safety, and its rollback() on error throws "no active + // transaction", masking the real SQL error. So run directly and let the + // genuine failure propagate to the catch below (and into the logs). + if ($db !== null) { + // Fresh install applies the module's master schema (database.sql). + ModuleMigrator::install($modulePath, $db, (string) $targetVersion); } + $module->install(); } catch (\Throwable $e) { $this->setState($name, \XcVm\Core\Enum\ModuleState::Failed); throw $e; @@ -335,16 +334,14 @@ class ModuleManager { } $module = $this->loadModuleInstance($name); - $overrides = $this->readOverrides(); - $installedVersion = $overrides[$name]['installed_version'] - ?: ($this->manifestVersion($name) ?? $module->getVersion()); // The module's own uninstall() hook runs first (it clears the data/rows - // it created), then the schema migrations it owns are reversed (down). + // it created), then the module's schema is torn down via its single + // teardown file (database_drop.sql). $module->uninstall(); $db = $this->getDb() ?? \XcVm\Infrastructure\Database\DatabaseFactory::get(); if ($db !== null) { - ModuleMigrator::down($this->modulePathFor($name), $db, (string) $installedVersion); + ModuleMigrator::uninstall($this->modulePathFor($name), $db); } $this->clearInstalledVersion($name); diff --git a/src/Core/Module/ModuleMigrator.php b/src/Core/Module/ModuleMigrator.php index 61a3b969..466c22c2 100644 --- a/src/Core/Module/ModuleMigrator.php +++ b/src/Core/Module/ModuleMigrator.php @@ -3,27 +3,31 @@ namespace XcVm\Core\Module; /** - * ModuleMigrator — file-based database migrations for modules. + * ModuleMigrator — file-based database schema for modules. * - * Each module ships its schema in a `migrations/` sub-directory as paired - * version files: + * A module ships its schema the same way core does — one master file plus a + * folder of forward version deltas, and a single teardown file: * - * Modules//migrations/.up.sql — apply (CREATE/ALTER/INSERT) - * Modules//migrations/.down.sql — reverse (DROP/ALTER) + * Modules//database.sql — master schema (full current CREATE/seed) + * Modules//database_drop.sql — teardown (DROP every table the module owns) + * Modules//migrations/.sql — forward delta applied between versions * - * Migrations are applied in ascending semver order on install/update and - * reversed in descending order on uninstall. The module's recorded - * installed_version (config/modules.php) is the watermark — no separate - * per-module tracking table is needed: + * The three roles map onto the lifecycle: * - * install → up(null, targetVersion) (every up file ≤ target) - * update → up(fromVersion, targetVersion) (up files in (from, target]) - * uninstall → down(installedVersion) (every down file ≤ installed, desc) + * install → database.sql (fresh install = the current schema) + * update → migrations/.sql in (from, to] (only the deltas since installed) + * uninstall → database_drop.sql (one file drops everything) + * + * The master `database.sql` must always reflect the LATEST schema (every delta + * folded in), exactly like core's `bin/install/database.sql`. Fresh installs run + * ONLY the master; the recorded installed_version (config/modules.php) is the + * watermark, so the deltas never replay. Migrations exist purely to upgrade + * panels installed on an older version. There is a fallback: a module that ships + * no master (delta-only) still installs by replaying every delta ≤ target. * * Statement splitting mirrors {@see \XcVm\Core\Database\MigrationRunner}: the * file is split on `;`, lines beginning with `--` are stripped, and each - * remaining statement is executed individually. A failing statement throws so - * the caller's install transaction rolls back. + * remaining statement is executed individually. A failing statement throws. * * @package XC_VM_Core_Module * @author Divarion_D @@ -34,17 +38,62 @@ namespace XcVm\Core\Module; class ModuleMigrator { /** - * Apply `up` migrations for versions in the (`$from`, `$to`] range. + * Apply the module's master schema on a fresh install. + * + * Runs `database.sql` (the full current schema) when present. A module that + * ships no master but only version deltas falls back to replaying every + * `migrations/.sql` up to `$to` — this keeps delta-only modules + * installable. + * + * @param string $modulePath Absolute path of the module directory. + * @param object $db Database handler (query() API). + * @param string $to Target version (used only by the delta fallback). + * @return string[] What ran: `['database.sql']`, or the replayed delta versions. + */ + public static function install(string $modulePath, object $db, string $to): array { + $master = $modulePath . '/database.sql'; + if (is_file($master)) { + self::runFile($db, $master); + return ['database.sql']; + } + // No master schema — replay forward deltas up to the target version. + return self::up($modulePath, $db, null, $to); + } + + /** + * Tear the module's schema down on uninstall. + * + * Runs the single `database_drop.sql` when present. No-op if the module ships + * no teardown file. + * + * @param string $modulePath Absolute path of the module directory. + * @param object $db Database handler. + * @return string[] `['database_drop.sql']` if it ran, otherwise `[]`. + */ + public static function uninstall(string $modulePath, object $db): array { + $drop = $modulePath . '/database_drop.sql'; + if (is_file($drop)) { + self::runFile($db, $drop); + return ['database_drop.sql']; + } + return []; + } + + /** + * Apply forward version deltas for versions in the (`$from`, `$to`] range. + * + * Used on update to bring an already-installed panel up to the current + * schema. Deltas are forward-only — teardown is handled by database_drop.sql. * * @param string $modulePath Absolute path of the module directory. - * @param object $db Database handler (query()/affected API). - * @param string|null $from Already-applied version, or null for a fresh install. + * @param object $db Database handler. + * @param string|null $from Already-applied version, or null to run all ≤ $to. * @param string $to Target version (inclusive). - * @return string[] Versions whose up migration ran, in apply order. + * @return string[] Versions whose delta ran, in apply order. */ public static function up(string $modulePath, object $db, ?string $from, string $to): array { $applied = []; - foreach (self::discover($modulePath, 'up') as [$version, $file]) { + foreach (self::discover($modulePath) as [$version, $file]) { if ($from !== null && version_compare($version, $from, '<=')) { continue; } @@ -58,50 +107,32 @@ class ModuleMigrator { } /** - * Apply `down` migrations for every version ≤ `$upTo`, newest first. - * - * @param string $modulePath Absolute path of the module directory. - * @param object $db Database handler. - * @param string $upTo Highest version to reverse (inclusive). - * @return string[] Versions whose down migration ran, in apply order. - */ - public static function down(string $modulePath, object $db, string $upTo): array { - $migrations = self::discover($modulePath, 'down'); - usort($migrations, static fn($a, $b) => version_compare($b[0], $a[0])); // descending - - $applied = []; - foreach ($migrations as [$version, $file]) { - if (version_compare($version, $upTo, '>')) { - continue; - } - self::runFile($db, $file); - $applied[] = $version; - } - return $applied; - } - - /** - * Whether the module ships any migration files at all. + * Whether the module ships any schema files at all (master, teardown, or deltas). */ public static function has(string $modulePath): bool { - $dir = $modulePath . '/migrations'; - return is_dir($dir) && !empty(glob($dir . '/*.{up,down}.sql', GLOB_BRACE)); + return is_file($modulePath . '/database.sql') + || is_file($modulePath . '/database_drop.sql') + || !empty(self::discover($modulePath)); } /** - * Discover migration files of one direction, sorted ascending by version. + * Discover forward delta files, sorted ascending by version. + * + * Files are `migrations/.sql` (forward-only; no `.up`/`.down` suffix — + * teardown lives in database_drop.sql). Anything not matching dotted-numeric + * semver is ignored. * * @return array [version, absolute path] */ - private static function discover(string $modulePath, string $direction): array { + private static function discover(string $modulePath): array { $dir = $modulePath . '/migrations'; if (!is_dir($dir)) { return []; } $out = []; - foreach (glob($dir . '/*.' . $direction . '.sql') ?: [] as $file) { - $version = basename($file, '.' . $direction . '.sql'); + foreach (glob($dir . '/*.sql') ?: [] as $file) { + $version = basename($file, '.sql'); // Accept dotted numeric semver only (e.g. 1.0.0, 2.1); ignore anything else. if (preg_match('/^\d+(?:\.\d+)*$/', $version)) { $out[] = [$version, $file]; @@ -112,7 +143,7 @@ class ModuleMigrator { } /** - * Execute every statement in a migration file. Throws on the first failure. + * Execute every statement in a SQL file. Throws on the first failure. */ private static function runFile(object $db, string $file): void { $sql = trim((string) file_get_contents($file)); diff --git a/src/Modules/watch/WatchModule.php b/src/Modules/watch/WatchModule.php index e1bb6448..3db6d90f 100644 --- a/src/Modules/watch/WatchModule.php +++ b/src/Modules/watch/WatchModule.php @@ -60,7 +60,7 @@ class WatchModule extends BaseModule { } public function getVersion(): string { - return '1.0.0'; + return '1.0.1'; } /** diff --git a/src/Modules/watch/migrations/1.0.0.up.sql b/src/Modules/watch/database.sql similarity index 94% rename from src/Modules/watch/migrations/1.0.0.up.sql rename to src/Modules/watch/database.sql index 4c438d05..14a615d1 100644 --- a/src/Modules/watch/migrations/1.0.0.up.sql +++ b/src/Modules/watch/database.sql @@ -1,6 +1,8 @@ --- watch module schema — version 1.0.0 (up) --- Owns: watch_categories, watch_folders, watch_logs, watch_refresh. --- plex depends on watch and reuses watch_categories + watch_folders. +-- watch module — master schema (current version) +-- Full CREATE + seed for a fresh install. Must always reflect the LATEST schema +-- (every migrations/.sql delta folded in), mirroring core's +-- bin/install/database.sql. Owns: watch_categories, watch_folders, watch_logs, +-- watch_refresh. plex depends on watch and reuses watch_categories + watch_folders. CREATE TABLE IF NOT EXISTS `watch_categories` ( `id` int(11) NOT NULL AUTO_INCREMENT, @@ -22,6 +24,7 @@ CREATE TABLE IF NOT EXISTS `watch_folders` ( `bouquets` varchar(4096) COLLATE utf8_unicode_ci DEFAULT '[]', `last_run` int(32) DEFAULT '0', `active` int(1) DEFAULT '1', + `delete_missing` tinyint(1) DEFAULT '0', `disable_tmdb` int(1) DEFAULT '0', `ignore_no_match` int(1) DEFAULT '0', `auto_subtitles` int(1) DEFAULT '0', diff --git a/src/Modules/watch/database_drop.sql b/src/Modules/watch/database_drop.sql new file mode 100644 index 00000000..e4330a46 --- /dev/null +++ b/src/Modules/watch/database_drop.sql @@ -0,0 +1,7 @@ +-- watch module — teardown (single deletion file) +-- Drops every table the module owns. Runs on uninstall. plex is uninstalled +-- first (it depends on watch), so its data is already gone by the time these run. +DROP TABLE IF EXISTS `watch_refresh`; +DROP TABLE IF EXISTS `watch_logs`; +DROP TABLE IF EXISTS `watch_folders`; +DROP TABLE IF EXISTS `watch_categories`; diff --git a/src/Modules/watch/migrations/1.0.0.down.sql b/src/Modules/watch/migrations/1.0.0.down.sql deleted file mode 100644 index 5e48b870..00000000 --- a/src/Modules/watch/migrations/1.0.0.down.sql +++ /dev/null @@ -1,7 +0,0 @@ --- watch module schema — version 1.0.0 (down) --- Reverses 1.0.0.up.sql. Plex is uninstalled first (it depends on watch), --- so its data is already gone by the time these run. -DROP TABLE IF EXISTS `watch_refresh`; -DROP TABLE IF EXISTS `watch_logs`; -DROP TABLE IF EXISTS `watch_folders`; -DROP TABLE IF EXISTS `watch_categories`; diff --git a/src/Modules/watch/migrations/1.0.1.sql b/src/Modules/watch/migrations/1.0.1.sql new file mode 100644 index 00000000..3f55e3ef --- /dev/null +++ b/src/Modules/watch/migrations/1.0.1.sql @@ -0,0 +1,7 @@ +-- watch module — version delta 1.0.1 (forward-only) +-- Upgrades panels installed on an older schema: adds the delete_missing flag to +-- watch_folders. Fresh installs get this column from database.sql (master) and +-- never run this file. Idempotent, so it is a no-op where the column already +-- exists (e.g. from the former core migration 004_add_watch_delete_missing.sql). +ALTER TABLE `watch_folders` + ADD COLUMN IF NOT EXISTS `delete_missing` tinyint(1) DEFAULT 0 AFTER `active`; diff --git a/src/migrations/004_add_watch_delete_missing.sql b/src/migrations/004_add_watch_delete_missing.sql deleted file mode 100644 index fe7600c5..00000000 --- a/src/migrations/004_add_watch_delete_missing.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Add delete_missing flag to watch_folders -ALTER TABLE `watch_folders` - ADD COLUMN IF NOT EXISTS `delete_missing` tinyint(1) DEFAULT 0 AFTER `active`;