Files
shopdb-flask/plugins/tools/migrations/versions/0001_securesend_baseline.py
cproudlock b96637e1a1
Some checks failed
CI / backend (push) Failing after 11s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 11s
CI / migrations-mysql (push) Failing after 7s
Let a baseline meet a table that is already there
Reported: `flask plugin upgrade-all` failed the tools plugin with "table
securesendsecrets already exists".

Every post-cutover baseline emitted a plain op.create_table. That is correct
only while a table's existence and its plugin's Alembic stamp can never
disagree, and they can. Anything that builds tables from the models -
db.create_all(), which `flask db-utils create-all` calls - creates them and
stamps nothing. A run that creates a table and then fails before its stamp
commits leaves the same split, and re-running is the obvious thing to try. In
both cases upgrade-all meets existing tables with an unstamped chain and the
create is fatal.

Nothing caught it because the migration test DROPS the post-cutover tables
db.create_all() built before running upgrade-all. That arranges the one state
where an unguarded create works, and never the state the report came from. The
new test is the same sequence with the drop removed for the plugin under test.

create_table_if_missing and create_index_if_missing are in alembic_template.py
next to create_plugin_tables, which had the idempotency rule right and turns out
to be called by nobody - its docstring claimed every 0001 used it. Fixed the
docstring rather than rewiring the baselines onto it, because the reason they
hand-write their DDL still stands: the helper's per-plugin MetaData cannot
resolve a foreign key to a core table at CreateTable-compile time.

INDEXES ARE MATCHED ON COLUMNS, NOT NAME, and that distinction matters. The two
creators disagree about names - db.create_all() takes SQLAlchemy's default
convention (ix_securesendsecrets_token) and a baseline spells it the way this
repo's naming rules require (ixsecuresendsecretstoken). A name-keyed guard would
have called the index missing and added a second one over the same column, which
MySQL permits with a warning, so every affected site would have quietly grown
duplicates. The test asserts no table ends up with two indexes over the same
columns.

Applied to all nine table-creating plugin migrations, not just the tools
baseline that was reported: the four later migrations that create tables have
the same exposure, and geenforce, measuringtools, printedparts and backups are
the same defect waiting for a different site.

WHAT THIS DOES NOT FIX. It makes a baseline idempotent, not a whole chain. A
longer chain can still meet an ALTER whose column is already present -
printedparts adding gagelabtag to a table create_all built at head is the case
that showed up while testing. Guarding every op one at a time is the wrong
answer to that; a database built from the models is already AT head and its
chains want stamping rather than replaying, and there is no `flask plugin stamp`
today. Left alone deliberately rather than half-built, and the new test is
scoped to single-baseline plugins so it does not pretend otherwise.
2026-08-24 09:28:37 -04:00

69 lines
3.0 KiB
Python

"""tools plugin baseline: the secure send table.
Creates the one table this plugin owns. Tech Tools was schemaless until secure
send arrived: a link that expires and dies on first read has to keep the
ciphertext somewhere until it is claimed. Not an asset extension: a shared
credential is a short-lived event rather than a thing the site inventories, so
there is no assetid and no asset type.
Explicit Alembic ops rather than the create_plugin_tables helper, matching the
backups baseline: the helper builds a per-plugin MetaData filtered to this
plugin's tables, so the foreign key to the core users table cannot resolve at
CreateTable-compile time.
"""
from alembic import op
from shopdb.plugins.alembic_template import (
create_index_if_missing,
create_table_if_missing,
)
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'tools0001securesend'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
create_table_if_missing(
'securesendsecrets',
sa.Column('secretid', sa.Integer(), nullable=False),
# 128 bits of randomness as hex. With no login on the read side this is
# the credential, so it must never be a sequence.
sa.Column('token', sa.String(length=64), nullable=False),
sa.Column('ciphertext', sa.Text(), nullable=False),
sa.Column('iv', sa.String(length=64), nullable=False),
sa.Column('expiresat', sa.DateTime(), nullable=False),
sa.Column('viewsremaining', sa.Integer(), nullable=False,
server_default='1'),
sa.Column('createdbyuserid', sa.Integer(), nullable=True),
sa.Column('lastviewedat', sa.DateTime(), nullable=True),
sa.Column('viewcount', sa.Integer(), nullable=False, server_default='0'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False,
server_default=sa.true()),
sa.ForeignKeyConstraint(['createdbyuserid'], ['users.userid'],
ondelete='SET NULL'),
sa.PrimaryKeyConstraint('secretid'),
)
# Unique, because the token is looked up on every read and two rows sharing
# one would make a link ambiguous.
create_index_if_missing('ixsecuresendsecretstoken', 'securesendsecrets',
['token'], unique=True)
# Expiry sweeps scan on this.
create_index_if_missing('ixsecuresendsecretsexpiresat', 'securesendsecrets',
['expiresat'])
create_index_if_missing('ixsecuresendsecretscreatedby', 'securesendsecrets',
['createdbyuserid'])
def downgrade():
op.drop_index('ixsecuresendsecretscreatedby', table_name='securesendsecrets')
op.drop_index('ixsecuresendsecretsexpiresat', table_name='securesendsecrets')
op.drop_index('ixsecuresendsecretstoken', table_name='securesendsecrets')
op.drop_table('securesendsecrets')