diff --git a/plugins/backups/migrations/versions/0003_backups_clear_backfilled_lastseen.py b/plugins/backups/migrations/versions/0003_backups_clear_backfilled_lastseen.py index 81e3374..f321bc1 100644 --- a/plugins/backups/migrations/versions/0003_backups_clear_backfilled_lastseen.py +++ b/plugins/backups/migrations/versions/0003_backups_clear_backfilled_lastseen.py @@ -30,7 +30,34 @@ def upgrade(): columns = {c['name'] for c in sa.inspect(op.get_bind()).get_columns('backuprevisions')} if 'lastseenat' in columns: - op.execute('UPDATE backuprevisions SET lastseenat = NULL') + # SCOPED TO THE BACKFILL, not to every row. An unscoped UPDATE was + # correct exactly once; re-running it destroys evidence, and this chain + # HAS been re-running on MySQL (see the missing commit in + # shopdb/plugins/alembic_template.py). A migration that deletes data has + # to assume it will run twice. + # + # TWO CONDITIONS, and both are needed: + # + # 1. The backfill's actual signature is COALESCE(collectedat, createdat) + # - that is the expression 0002 wrote. Matching `lastseenat = + # collectedat` alone misses every row whose collectedat is NULL, + # because NULL comparison is never true, so exactly the rows carrying + # the most invented value would have kept it forever. + # + # 2. A date bound, because value equality on its own is not a signature + # on MySQL. db.DateTime is second-precision there (SQLite keeps + # microseconds, which is why no test could show this), and the + # collector writes collectedat and lastseenat in one statement - so a + # genuinely fresh revision that arrived without a payload collectedat + # has the two stamps equal to the second and would be read as a + # backfill and wiped. + # + # The cutoff is the day this correction ships. Everything the backfill + # touched predates it by construction; anything created after it is a + # live collection this migration must never touch. + op.execute("UPDATE backuprevisions SET lastseenat = NULL " + "WHERE lastseenat = COALESCE(collectedat, createdat) " + "AND createdat < '2026-08-14 00:00:00'") def downgrade(): diff --git a/plugins/geenforce/migrations/versions/0003_report_subtype.py b/plugins/geenforce/migrations/versions/0003_report_subtype.py index 0fafc09..e0ef575 100644 --- a/plugins/geenforce/migrations/versions/0003_report_subtype.py +++ b/plugins/geenforce/migrations/versions/0003_report_subtype.py @@ -19,10 +19,28 @@ branch_labels = None depends_on = None +def _columns(bind): + insp = sa.inspect(bind) + if 'manifestenforcementreports' not in insp.get_table_names(): + return None + return {c['name'] for c in insp.get_columns('manifestenforcementreports')} + + def upgrade(): + # Guarded, like network0003prefix. On a FRESH database the table is built + # from the SQLAlchemy models, which already declare this column, so an + # unconditional add fails with "duplicate column name" - and this chain has + # been re-running its head on MySQL (see the missing commit in + # shopdb/plugins/alembic_template.py), so a redeploy hit the same wall. + columns = _columns(op.get_bind()) + if columns is None or 'subtype' in columns: + return op.add_column('manifestenforcementreports', sa.Column('subtype', sa.String(length=50), nullable=True)) def downgrade(): + columns = _columns(op.get_bind()) + if columns is None or 'subtype' not in columns: + return op.drop_column('manifestenforcementreports', 'subtype') diff --git a/shopdb/plugins/alembic_template.py b/shopdb/plugins/alembic_template.py index 9f9caca..cea3279 100644 --- a/shopdb/plugins/alembic_template.py +++ b/shopdb/plugins/alembic_template.py @@ -180,3 +180,15 @@ def run_migrations(): ) with context.begin_transaction(): context.run_migrations() + # SAME FIX AS migrations/env.py. MySQL DDL commits implicitly, which + # flushes everything queued before it - including the PREVIOUS + # migration's version stamp. The LAST migration of a run has no DDL + # after it, so its stamp is rolled back when the connection closes: + # the schema change survives, alembic_version stays one revision + # behind, and `flask plugin upgrade-all` exits 0 having silently + # re-run that migration - again on every deploy after. + # + # Core got this fix; the per-plugin chains run through THIS file and + # did not, so every plugin head has been re-running on MySQL. That is + # only invisible while every head happens to be idempotent. + connection.commit()