refactor(modules): master/drop/migrations schema layout + harden install

Mirror core's DB layout at the module level: one master database.sql (full
current schema), one database_drop.sql (teardown), and forward-only
migrations/<semver>.sql deltas. ModuleMigrator gains install()/uninstall();
up() reads migrations/*.sql; down() removed.

- watch: database.sql (delete_missing folded into watch_folders),
  database_drop.sql, migrations/1.0.1.sql; drop 1.0.0.up/down.sql;
  getVersion() -> 1.0.1 (matches module.json).
- Move core migration 004_add_watch_delete_missing.sql into the watch module.
- installModule no longer wraps the DDL migration in a transaction: MySQL/MariaDB
  implicitly commit DDL, so the wrapping rollback() only masked the real SQL error.
- syncBundledModules retries a previously-failed install (skips only admin-disabled)
  so `console.php status` self-heals.
This commit is contained in:
Divarion-D
2026-07-02 21:28:33 +03:00
parent b2e61c7d79
commit 58656fa396
8 changed files with 123 additions and 88 deletions
+21 -24
View File
@@ -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);
+81 -50
View File
@@ -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/<name>/migrations/<semver>.up.sql — apply (CREATE/ALTER/INSERT)
* Modules/<name>/migrations/<semver>.down.sql — reverse (DROP/ALTER)
* Modules/<name>/database.sql — master schema (full current CREATE/seed)
* Modules/<name>/database_drop.sql — teardown (DROP every table the module owns)
* Modules/<name>/migrations/<semver>.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/<semver>.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 <https://github.com/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/<semver>.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/<semver>.sql` (forward-only; no `.up`/`.down` suffix —
* teardown lives in database_drop.sql). Anything not matching dotted-numeric
* semver is ignored.
*
* @return array<int, array{0: string, 1: string}> [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));
+1 -1
View File
@@ -60,7 +60,7 @@ class WatchModule extends BaseModule {
}
public function getVersion(): string {
return '1.0.0';
return '1.0.1';
}
/**
@@ -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/<semver>.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',
+7
View File
@@ -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`;
@@ -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`;
+7
View File
@@ -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`;
@@ -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`;