"""Move endpointlogcollections into the chain that owns it. Revision ID: endpoints0025logcollections Revises: endpoints0024bootstraptokens Create Date: 2026-09-19 WHY THIS REVISION EXISTS. endpointlogcollections was created by a CORE migration (7d51_endpoint_logcollections) even though PLUGIN_TABLE_OWNERS has always listed it, and endpointcommands, as owned by this plugin. The core table carries a foreign key onto endpointcommands, which only 0002 creates. Core runs before any plugin chain, and a plugin chain only runs once the plugin is enabled, which cannot happen before the schema exists. So on a FRESH database there is no ordering in which the core revision can succeed: MySQL rejects it with 1824, "Failed to open the referenced table 'endpointcommands'". Nothing caught it because TestingConfig runs on sqlite:///:memory:, and SQLite accepts a foreign key onto a table that does not exist. The failure is MySQL-only, and therefore invisible to the whole test suite. Existing sites already ran the core revision and have the table, so this is guarded and is a no-op there. 7d51 is guarded in the same change so it skips when endpointcommands is absent, which is exactly the fresh-install case this revision then handles. Downgrade drops the table. """ from alembic import op from shopdb.plugins.alembic_template import ( create_index_if_missing, create_table_if_missing, ) import sqlalchemy as sa revision = 'endpoints0025logcollections' down_revision = 'endpoints0024bootstraptokens' branch_labels = None depends_on = None plugin_name = 'endpoints' COLLECTIONS = 'endpointlogcollections' def upgrade(): created = create_table_if_missing( COLLECTIONS, sa.Column('endpointlogcollectionid', sa.Integer(), nullable=False), sa.Column('assetid', sa.Integer(), nullable=False), sa.Column('endpointcommandid', sa.Integer(), nullable=True), sa.Column('sourcepath', sa.String(length=512), nullable=False, comment='Path as the AGENT resolved it, which may differ ' 'from what the operator typed once environment ' 'variables are expanded endpoint-side.'), sa.Column('content', sa.Text(length=16777215), nullable=True), sa.Column('filesizebytes', sa.BigInteger(), nullable=True, comment='Size of the file on disk. Differs from contentbytes ' 'when the stored copy is a truncated tail.'), sa.Column('contentbytes', sa.Integer(), nullable=True), sa.Column('linecount', sa.Integer(), nullable=True), sa.Column('istruncated', sa.Boolean(), nullable=False, server_default=sa.false(), comment='True when content is only the tail of a larger file.'), sa.Column('filemodifiedat', sa.DateTime(), nullable=True), sa.Column('collectedbyuserid', sa.Integer(), nullable=True), sa.Column('collectedbyusername', sa.String(length=100), nullable=True, comment='Username snapshot; the id alone becomes unreadable ' 'if the user is later removed.'), sa.Column('collectedat', sa.DateTime(), nullable=False), sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'), # SET NULL rather than CASCADE: commands are reaped and expired over # time, and a collected log an operator is still reading must not # vanish because its originating command row was cleaned up. sa.ForeignKeyConstraint(['endpointcommandid'], ['endpointcommands.endpointcommandid'], ondelete='SET NULL'), sa.ForeignKeyConstraint(['collectedbyuserid'], ['users.userid'], ondelete='SET NULL'), sa.PrimaryKeyConstraint('endpointlogcollectionid'), ) if not created: return create_index_if_missing('ix_endpointlogcollections_assetid', COLLECTIONS, ['assetid']) create_index_if_missing('ix_endpointlogcollections_collectedat', COLLECTIONS, ['collectedat']) # Serves both hot paths: listing an asset's history newest-first, and # finding that asset's oldest rows to prune on insert. create_index_if_missing('idx_endpointlogcollections_assetcollected', COLLECTIONS, ['assetid', 'collectedat']) def downgrade(): from sqlalchemy import inspect if not inspect(op.get_bind()).has_table(COLLECTIONS): return op.drop_table(COLLECTIONS)