feat(plugin): persist declared metadata snapshots (#6467)

This commit is contained in:
InfinityPacer
2026-08-26 13:15:12 +08:00
committed by GitHub
parent bd0795082a
commit ac2a2b1129
26 changed files with 1374 additions and 147 deletions
+251 -12
View File
@@ -22,12 +22,18 @@ except ModuleNotFoundError:
from app.db.models.pluginidentity import PluginIdentity
MIGRATION = "database.versions.d2e4f6a8b0c1_3_0_9"
BASE_MIGRATION = "database.versions.d2e4f6a8b0c1_3_0_9"
INSTALLATION_MIGRATION = "database.versions.e4f7a1b2c3d5_3_0_10"
DECLARATION_MIGRATION = "database.versions.5f2a9c1e7b4d_3_0_12"
def _bind_migration(monkeypatch, connection):
def _bind_migration(
monkeypatch,
connection,
module_name: str = BASE_MIGRATION,
):
"""把迁移绑定到隔离数据库连接。"""
migration = importlib.import_module(MIGRATION)
migration = importlib.import_module(module_name)
context = MigrationContext.configure(connection)
monkeypatch.setattr(migration, "op", Operations(context))
return migration
@@ -36,13 +42,27 @@ def _bind_migration(monkeypatch, connection):
def test_plugin_identity_migration_upgrades_twice_and_downgrades(
monkeypatch,
) -> None:
"""SQLite schema 应可重复升级并完整删除 dormant 身份表"""
"""SQLite 应可从旧身份表升级到声明快照并按逆序完整回滚"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
migration.upgrade()
installation = _bind_migration(
monkeypatch,
connection,
INSTALLATION_MIGRATION,
)
installation.upgrade()
installation.upgrade()
declaration = _bind_migration(
monkeypatch,
connection,
DECLARATION_MIGRATION,
)
declaration.upgrade()
declaration.upgrade()
inspector = sa.inspect(connection)
assert "pluginidentity" in inspector.get_table_names()
@@ -65,9 +85,23 @@ def test_plugin_identity_migration_upgrades_twice_and_downgrades(
"ck_pluginidentity_normalized_plugin_id",
"ck_pluginidentity_revision",
}
assert "plugininstallation" in inspector.get_table_names()
declaration.downgrade()
installation.downgrade()
migration.downgrade()
assert "pluginidentity" not in sa.inspect(connection).get_table_names()
assert not {
"pluginidentity",
"plugininstallation",
} & set(sa.inspect(connection).get_table_names())
migration.upgrade()
installation.upgrade()
declaration.upgrade()
assert {
column["name"]
for column in sa.inspect(connection).get_columns("pluginidentity")
} == {column.name for column in PluginIdentity.__table__.columns}
def test_plugin_identity_migration_accepts_fresh_current_schema(monkeypatch) -> None:
@@ -75,7 +109,11 @@ def test_plugin_identity_migration_accepts_fresh_current_schema(monkeypatch) ->
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
PluginIdentity.__table__.create(connection)
migration = _bind_migration(monkeypatch, connection)
migration = _bind_migration(
monkeypatch,
connection,
DECLARATION_MIGRATION,
)
migration.upgrade()
migration.upgrade()
@@ -86,9 +124,126 @@ def test_plugin_identity_migration_accepts_fresh_current_schema(monkeypatch) ->
} == {column.name for column in PluginIdentity.__table__.columns}
def test_plugin_identity_declaration_migration_backfills_and_restores_legacy_fields(
monkeypatch,
) -> None:
"""声明迁移应保守回填旧字段,并能在降级时恢复旧投影。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
base = _bind_migration(monkeypatch, connection)
base.upgrade()
table = sa.Table(
"pluginidentity",
sa.MetaData(),
autoload_with=connection,
)
connection.execute(
table.insert().values(
plugin_id="DemoPlugin",
normalized_plugin_id="demoplugin",
trusted_source_type="official",
trusted_source_key="github:jxxghp/moviepilot-plugins",
binding_basis="official_default",
payload_source_type="official",
payload_source_key="github:jxxghp/moviepilot-plugins",
declared_version="1.0.0",
package_generation="v3",
system_version=">=3.0.0",
supports_v3=True,
supports_v3t=False,
payload_receipt="sha256:" + "0" * 64,
revision=1,
created_at="2026-08-25T12:00:00+00:00",
updated_at="2026-08-25T12:00:00+00:00",
bound_at="2026-08-25T12:00:00+00:00",
payload_applied_at="2026-08-25T12:00:00+00:00",
)
)
unknown_id = connection.execute(
table.insert().values(
plugin_id="UnknownPlugin",
normalized_plugin_id="unknownplugin",
trusted_source_type="unknown",
binding_basis="legacy_unbound",
payload_source_type="unknown",
revision=1,
created_at="2026-08-25T12:00:00+00:00",
updated_at="2026-08-25T12:00:00+00:00",
).returning(table.c.id)
).scalar_one()
declaration = _bind_migration(
monkeypatch,
connection,
DECLARATION_MIGRATION,
)
declaration.upgrade()
current = sa.Table(
"pluginidentity",
sa.MetaData(),
autoload_with=connection,
)
row = connection.execute(
sa.select(current).where(current.c.plugin_id == "DemoPlugin")
).mappings().one()
assert row["declared_metadata"] == {
"schema_version": 1,
"declaration_version": None,
"manifest_matches_payload": False,
"manifest": {"system_version": ">=3.0.0"},
"runtime": {"v3": True, "v3t": False},
}
assert connection.execute(
sa.select(current.c.id).where(current.c.declared_metadata.is_(None))
).scalars().all() == [unknown_id]
declaration.downgrade()
restored = sa.Table(
"pluginidentity",
sa.MetaData(),
autoload_with=connection,
)
restored_row = connection.execute(
sa.select(restored).where(restored.c.plugin_id == "DemoPlugin")
).mappings().one()
assert restored_row["system_version"] == ">=3.0.0"
assert restored_row["supports_v3"] is True
assert restored_row["supports_v3t"] is False
assert "declared_metadata" not in restored.c
def test_plugin_identity_declaration_migration_rejects_partial_legacy_schema(
monkeypatch,
) -> None:
"""非 canonical 旧字段集合必须停止迁移,不能静默丢弃残余声明。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
base = _bind_migration(monkeypatch, connection)
base.upgrade()
with Operations(MigrationContext.configure(connection)).batch_alter_table(
"pluginidentity"
) as batch_op:
batch_op.drop_column("supports_v3t")
declaration = _bind_migration(
monkeypatch,
connection,
DECLARATION_MIGRATION,
)
with pytest.raises(RuntimeError, match="旧声明字段不完整"):
declaration.upgrade()
columns = {
column["name"]
for column in sa.inspect(connection).get_columns("pluginidentity")
}
assert "declared_metadata" not in columns
assert {"system_version", "supports_v3"} <= columns
def test_plugin_identity_migration_matches_postgresql_identity() -> None:
"""独立 Alembic 路径应保留 PostgreSQL 循环 Identity 主键。"""
migration = importlib.import_module(MIGRATION)
migration = importlib.import_module(BASE_MIGRATION)
metadata = sa.MetaData()
table = sa.Table(
"pluginidentity",
@@ -148,25 +303,80 @@ def test_plugin_identity_migration_runs_on_postgresql(monkeypatch) -> None:
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
migration.upgrade()
table = sa.Table(
installation = _bind_migration(
monkeypatch,
connection,
INSTALLATION_MIGRATION,
)
installation.upgrade()
legacy_table = sa.Table(
"pluginidentity",
sa.MetaData(),
autoload_with=connection,
)
inserted_id = connection.execute(
table.insert().values(
legacy_table.insert().values(
plugin_id="DemoPlugin",
normalized_plugin_id="demoplugin",
trusted_source_type="official",
trusted_source_key="github:jxxghp/moviepilot-plugins",
binding_basis="official_default",
payload_source_type="official",
payload_source_key="github:jxxghp/moviepilot-plugins",
declared_version="1.0.0",
package_generation="v3",
system_version=">=3.0.0",
supports_v3=True,
supports_v3t=False,
payload_receipt="sha256:" + "0" * 64,
revision=1,
created_at="2026-08-25T12:00:00+00:00",
updated_at="2026-08-25T12:00:00+00:00",
bound_at="2026-08-25T12:00:00+00:00",
payload_applied_at="2026-08-25T12:00:00+00:00",
).returning(legacy_table.c.id)
).scalar_one()
assert inserted_id == 1
unknown_id = connection.execute(
legacy_table.insert().values(
plugin_id="UnknownPlugin",
normalized_plugin_id="unknownplugin",
trusted_source_type="unknown",
binding_basis="legacy_unbound",
payload_source_type="unknown",
revision=1,
created_at="2026-08-25T12:00:00+00:00",
updated_at="2026-08-25T12:00:00+00:00",
).returning(table.c.id)
).returning(legacy_table.c.id)
).scalar_one()
assert inserted_id == 1
assert unknown_id == 2
declaration = _bind_migration(
monkeypatch,
connection,
DECLARATION_MIGRATION,
)
declaration.upgrade()
declaration.upgrade()
table = sa.Table(
"pluginidentity",
sa.MetaData(),
autoload_with=connection,
)
row = connection.execute(
sa.select(table).where(table.c.id == inserted_id)
).mappings().one()
assert row["declared_metadata"] == {
"schema_version": 1,
"declaration_version": None,
"manifest_matches_payload": False,
"manifest": {"system_version": ">=3.0.0"},
"runtime": {"v3": True, "v3t": False},
}
unknown_row = connection.execute(
sa.select(table).where(table.c.id == unknown_id)
).mappings().one()
assert unknown_row["declared_metadata"] is None
with pytest.raises(sa.exc.IntegrityError):
with connection.begin_nested():
@@ -183,6 +393,35 @@ def test_plugin_identity_migration_runs_on_postgresql(monkeypatch) -> None:
)
)
declaration.downgrade()
restored = sa.Table(
"pluginidentity",
sa.MetaData(),
autoload_with=connection,
)
restored_row = connection.execute(
sa.select(restored).where(restored.c.id == inserted_id)
).mappings().one()
assert restored_row["system_version"] == ">=3.0.0"
assert restored_row["supports_v3"] is True
assert restored_row["supports_v3t"] is False
declaration.upgrade()
reupgraded = sa.Table(
"pluginidentity",
sa.MetaData(),
autoload_with=connection,
)
reupgraded_row = connection.execute(
sa.select(reupgraded).where(reupgraded.c.id == inserted_id)
).mappings().one()
assert reupgraded_row["declared_metadata"]["runtime"] == {
"v3": True,
"v3t": False,
}
declaration.downgrade()
installation.downgrade()
migration.downgrade()
assert "pluginidentity" not in sa.inspect(connection).get_table_names()
finally: