mirror of
https://github.com/Vateron-Media/XC_VM.git
synced 2026-10-10 04:02:42 +02:00
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:
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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',
|
||||
@@ -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`;
|
||||
@@ -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`;
|
||||
Reference in New Issue
Block a user