"""capture the operational schema baseline Revision ID: 0006_operational_schema_baseline Revises: 0005_miner_inventory Create Date: 2026-07-27 This migration adopts tables that older MinerOps builds created at runtime. The IF NOT EXISTS clauses allow it to run against both an existing deployment and a new database. """ from typing import Sequence, Union from alembic import op revision: str = "0006_operational_schema_baseline" down_revision: Union[str, None] = "0005_miner_inventory" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: op.execute( """ ALTER TABLE miners ADD COLUMN IF NOT EXISTS manual_details JSONB NOT NULL DEFAULT '{}'::jsonb, ADD COLUMN IF NOT EXISTS manual_details_updated_at TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS manual_details_updated_by TEXT, ADD COLUMN IF NOT EXISTS psu_model TEXT, ADD COLUMN IF NOT EXISTS customer_sn TEXT, ADD COLUMN IF NOT EXISTS api3_supported BOOLEAN, ADD COLUMN IF NOT EXISTS api3_api_version TEXT, ADD COLUMN IF NOT EXISTS api3_api_switch TEXT, ADD COLUMN IF NOT EXISTS api3_last_seen_at TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS api3_last_error TEXT, ADD COLUMN IF NOT EXISTS configured_performance_mode TEXT, ADD COLUMN IF NOT EXISTS configured_performance_mode_source TEXT, ADD COLUMN IF NOT EXISTS configured_performance_mode_updated_at TIMESTAMPTZ; ALTER TABLE miner_status_snapshots ADD COLUMN IF NOT EXISTS ip_address INET, ADD COLUMN IF NOT EXISTS mac_address TEXT, ADD COLUMN IF NOT EXISTS fan_speed_in DOUBLE PRECISION, ADD COLUMN IF NOT EXISTS fan_speed_out DOUBLE PRECISION, ADD COLUMN IF NOT EXISTS elapsed_seconds DOUBLE PRECISION; """ ) op.execute( """ CREATE TABLE IF NOT EXISTS miner_maintenance_events ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), miner_id UUID NOT NULL REFERENCES miners(id) ON DELETE CASCADE, event_type TEXT NOT NULL, event_status TEXT NOT NULL DEFAULT 'completed', event_title TEXT NOT NULL, description TEXT, event_at TIMESTAMPTZ NOT NULL DEFAULT now(), old_mac TEXT, new_mac TEXT, old_ip INET, new_ip INET, component_type TEXT, component_slot TEXT, component_serial TEXT, cost_amount NUMERIC(12, 2), downtime_minutes INTEGER, created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_miner_maintenance_events_miner_id ON miner_maintenance_events(miner_id); CREATE INDEX IF NOT EXISTS idx_miner_maintenance_events_event_at ON miner_maintenance_events(event_at DESC); CREATE INDEX IF NOT EXISTS idx_miner_maintenance_events_event_type ON miner_maintenance_events(event_type); CREATE TABLE IF NOT EXISTS location_positions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), room_id UUID REFERENCES rooms(id) ON DELETE SET NULL, room_name TEXT NOT NULL, rack_name TEXT NOT NULL, shelf_name TEXT NOT NULL, shelf_index INTEGER, position_index INTEGER NOT NULL, position_label TEXT, position_kind TEXT NOT NULL DEFAULT 'miner_slot', position_status TEXT NOT NULL DEFAULT 'available', expected_mac TEXT, miner_id UUID REFERENCES miners(id) ON DELETE SET NULL, equipment_label TEXT, notes TEXT, source_file TEXT, source_sheet TEXT, source_cell TEXT, raw_value TEXT, conflict_reason TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); DO $$ BEGIN ALTER TABLE location_positions ADD CONSTRAINT uq_location_positions_slot UNIQUE (room_name, rack_name, shelf_name, position_index); EXCEPTION WHEN duplicate_object OR duplicate_table THEN NULL; END $$; CREATE INDEX IF NOT EXISTS idx_location_positions_room ON location_positions(room_name); CREATE INDEX IF NOT EXISTS idx_location_positions_rack ON location_positions(room_name, rack_name); CREATE INDEX IF NOT EXISTS idx_location_positions_expected_mac ON location_positions(expected_mac); CREATE INDEX IF NOT EXISTS idx_location_positions_miner_id ON location_positions(miner_id); CREATE TABLE IF NOT EXISTS miner_location_states ( miner_id UUID PRIMARY KEY REFERENCES miners(id) ON DELETE CASCADE, asset_state TEXT NOT NULL DEFAULT 'waiting_placement', location_position_id UUID REFERENCES location_positions(id) ON DELETE SET NULL, state_notes TEXT, updated_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_miner_location_states_asset_state ON miner_location_states(asset_state); CREATE TABLE IF NOT EXISTS location_import_issues ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), import_batch_id UUID NOT NULL, issue_type TEXT NOT NULL, severity TEXT NOT NULL DEFAULT 'warning', message TEXT NOT NULL, mac_address TEXT, room_name TEXT, rack_name TEXT, shelf_name TEXT, position_index INTEGER, source_file TEXT, source_cell TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), resolved_at TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS idx_location_import_issues_batch ON location_import_issues(import_batch_id); CREATE INDEX IF NOT EXISTS idx_location_import_issues_mac ON location_import_issues(mac_address); """ ) op.execute( """ CREATE TABLE IF NOT EXISTS miner_command_events ( id TEXT PRIMARY KEY, miner_id TEXT NOT NULL, command_type TEXT NOT NULL, command_status TEXT NOT NULL, dry_run BOOLEAN NOT NULL DEFAULT true, requested_by_user_id TEXT, requested_by_username TEXT, target_ip TEXT, target_mac TEXT, request_payload JSONB, response_payload JSONB, error_message TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), completed_at TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS idx_miner_command_events_miner_id_created ON miner_command_events(miner_id, created_at DESC); CREATE TABLE IF NOT EXISTS miner_api_credentials ( miner_id TEXT PRIMARY KEY, api_account TEXT NOT NULL DEFAULT 'super', api_password TEXT NOT NULL, password_source TEXT NOT NULL DEFAULT 'minerops', updated_by_username TEXT, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS minerops_site_agents ( agent_id TEXT PRIMARY KEY, site_name TEXT NOT NULL, hostname TEXT, version TEXT, ip_address TEXT, last_checkin_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_payload JSONB NOT NULL DEFAULT '{}'::jsonb ); CREATE TABLE IF NOT EXISTS minerops_agent_snapshots ( id BIGSERIAL PRIMARY KEY, agent_id TEXT NOT NULL, site_name TEXT NOT NULL, snapshot_kind TEXT NOT NULL DEFAULT 'miner_poll', received_at TIMESTAMPTZ NOT NULL DEFAULT now(), miner_count INTEGER NOT NULL DEFAULT 0, payload JSONB NOT NULL ); CREATE INDEX IF NOT EXISTS idx_minerops_agent_snapshots_received ON minerops_agent_snapshots(received_at DESC); CREATE TABLE IF NOT EXISTS minerops_agent_command_results ( id BIGSERIAL PRIMARY KEY, agent_id TEXT NOT NULL, site_name TEXT NOT NULL, command_id TEXT, received_at TIMESTAMPTZ NOT NULL DEFAULT now(), status TEXT NOT NULL, payload JSONB NOT NULL ); CREATE INDEX IF NOT EXISTS idx_minerops_agent_command_results_received ON minerops_agent_command_results(received_at DESC); CREATE TABLE IF NOT EXISTS minerops_agent_command_queue ( id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, command_type TEXT NOT NULL, command_status TEXT NOT NULL DEFAULT 'pending', requested_by_user_id TEXT, requested_by_username TEXT, request_payload JSONB NOT NULL DEFAULT '{}'::jsonb, result_payload JSONB, error_message TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), claimed_at TIMESTAMPTZ, completed_at TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS idx_minerops_agent_command_queue_agent_status ON minerops_agent_command_queue(agent_id, command_status, created_at); CREATE TABLE IF NOT EXISTS minerops_agent_discoveries ( id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, site_name TEXT NOT NULL, room_name TEXT, ip_address INET, mac_address TEXT, model TEXT, hostname TEXT, miner_sn TEXT, raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb, discovery_status TEXT NOT NULL DEFAULT 'new', matched_miner_id UUID, first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_minerops_agent_discoveries_mac ON minerops_agent_discoveries(mac_address); CREATE INDEX IF NOT EXISTS idx_minerops_agent_discoveries_ip ON minerops_agent_discoveries(ip_address); """ ) op.execute( """ CREATE TABLE IF NOT EXISTS minerops_scheduled_bulk_actions ( id TEXT PRIMARY KEY, name TEXT, target_type TEXT NOT NULL, target_value TEXT, action TEXT NOT NULL DEFAULT 'performance_mode', performance_mode TEXT, repeat_rule TEXT NOT NULL DEFAULT 'once', scheduled_for TIMESTAMPTZ NOT NULL, status TEXT NOT NULL DEFAULT 'pending', agent_id TEXT NOT NULL DEFAULT 'plexus-windows-agent', requested_by_user_id TEXT, requested_by_username TEXT, queued_command_count INTEGER NOT NULL DEFAULT 0, last_queued_command_count INTEGER NOT NULL DEFAULT 0, run_count INTEGER NOT NULL DEFAULT 0, last_error TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), processed_at TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS idx_minerops_scheduled_bulk_actions_due ON minerops_scheduled_bulk_actions(status, scheduled_for); CREATE INDEX IF NOT EXISTS idx_minerops_scheduled_bulk_actions_created ON minerops_scheduled_bulk_actions(created_at DESC); CREATE TABLE IF NOT EXISTS miner_pool_snapshots ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), miner_id UUID, ip_address INET, mac_address TEXT, model TEXT, pool_index INTEGER, url TEXT, worker_user TEXT, pool_status TEXT, stratum_active BOOLEAN, accepted DOUBLE PRECISION, rejected DOUBLE PRECISION, stale DOUBLE PRECISION, get_failures DOUBLE PRECISION, remote_failures DOUBLE PRECISION, pool_rejected_percent DOUBLE PRECISION, pool_stale_percent DOUBLE PRECISION, last_share_time_epoch DOUBLE PRECISION, raw_pool_json JSONB NOT NULL DEFAULT '{}'::jsonb, agent_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); ALTER TABLE miner_pool_snapshots ADD COLUMN IF NOT EXISTS model TEXT, ADD COLUMN IF NOT EXISTS pool_index INTEGER, ADD COLUMN IF NOT EXISTS url TEXT, ADD COLUMN IF NOT EXISTS worker_user TEXT, ADD COLUMN IF NOT EXISTS pool_status TEXT, ADD COLUMN IF NOT EXISTS stratum_active BOOLEAN, ADD COLUMN IF NOT EXISTS accepted DOUBLE PRECISION, ADD COLUMN IF NOT EXISTS rejected DOUBLE PRECISION, ADD COLUMN IF NOT EXISTS stale DOUBLE PRECISION, ADD COLUMN IF NOT EXISTS get_failures DOUBLE PRECISION, ADD COLUMN IF NOT EXISTS remote_failures DOUBLE PRECISION, ADD COLUMN IF NOT EXISTS pool_rejected_percent DOUBLE PRECISION, ADD COLUMN IF NOT EXISTS pool_stale_percent DOUBLE PRECISION, ADD COLUMN IF NOT EXISTS last_share_time_epoch DOUBLE PRECISION, ADD COLUMN IF NOT EXISTS raw_pool_json JSONB NOT NULL DEFAULT '{}'::jsonb, ADD COLUMN IF NOT EXISTS agent_id TEXT; DO $$ DECLARE id_type TEXT; BEGIN SELECT data_type INTO id_type FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'miner_pool_snapshots' AND column_name = 'id'; IF id_type = 'text' THEN ALTER TABLE miner_pool_snapshots ALTER COLUMN id SET DEFAULT (gen_random_uuid())::text; ELSIF id_type = 'uuid' THEN ALTER TABLE miner_pool_snapshots ALTER COLUMN id SET DEFAULT gen_random_uuid(); END IF; END $$; CREATE INDEX IF NOT EXISTS idx_miner_pool_snapshots_miner_created ON miner_pool_snapshots(miner_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_miner_pool_snapshots_ip_created ON miner_pool_snapshots(ip_address, created_at DESC); CREATE TABLE IF NOT EXISTS miner_api3_enrichment_snapshots ( id BIGSERIAL PRIMARY KEY, miner_id UUID, agent_id TEXT, site_name TEXT, ip_address INET, mac_address TEXT, api_supported BOOLEAN, api_error TEXT, api_version TEXT, api_switch TEXT, model TEXT, miner_sn TEXT, power_sn TEXT, psu_model TEXT, device_info JSONB, miner_setting JSONB, miner_status JSONB, fan_setting JSONB, custom_data JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_miner_api3_enrichment_miner_created ON miner_api3_enrichment_snapshots(miner_id, created_at DESC); CREATE TABLE IF NOT EXISTS miner_api3_hashboard_snapshots ( id BIGSERIAL PRIMARY KEY, miner_id UUID, ip_address INET, mac_address TEXT, slot INTEGER, pcb_sn TEXT, hash_average_ths DOUBLE PRECISION, factory_hash_ths DOUBLE PRECISION, freq DOUBLE PRECISION, effective_chips INTEGER, chip_temp_min_c DOUBLE PRECISION, chip_temp_avg_c DOUBLE PRECISION, chip_temp_max_c DOUBLE PRECISION, raw_hashboard_json JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_miner_api3_hashboards_miner_created ON miner_api3_hashboard_snapshots(miner_id, created_at DESC); """ ) op.execute( """ CREATE TABLE IF NOT EXISTS miner_status_hourly_rollups ( miner_id UUID NOT NULL REFERENCES miners(id) ON DELETE CASCADE, bucket_at TIMESTAMPTZ NOT NULL, sample_count BIGINT NOT NULL, avg_mhs_av DOUBLE PRECISION, max_mhs_av DOUBLE PRECISION, avg_power_w DOUBLE PRECISION, max_chip_temp_c DOUBLE PRECISION, avg_chip_temp_c DOUBLE PRECISION, avg_pool_rejected_percent DOUBLE PRECISION, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (miner_id, bucket_at) ); CREATE INDEX IF NOT EXISTS idx_miner_status_hourly_bucket ON miner_status_hourly_rollups(bucket_at); CREATE TABLE IF NOT EXISTS miner_status_daily_rollups ( miner_id UUID NOT NULL REFERENCES miners(id) ON DELETE CASCADE, bucket_date DATE NOT NULL, sample_count BIGINT NOT NULL, avg_mhs_av DOUBLE PRECISION, max_mhs_av DOUBLE PRECISION, avg_power_w DOUBLE PRECISION, max_chip_temp_c DOUBLE PRECISION, avg_chip_temp_c DOUBLE PRECISION, avg_pool_rejected_percent DOUBLE PRECISION, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (miner_id, bucket_date) ); CREATE INDEX IF NOT EXISTS idx_miner_status_daily_bucket ON miner_status_daily_rollups(bucket_date); CREATE TABLE IF NOT EXISTS minerops_retention_runs ( id BIGSERIAL PRIMARY KEY, mode TEXT NOT NULL, raw_status_retention_days INTEGER NOT NULL, hourly_rollup_rows_before BIGINT NOT NULL DEFAULT 0, hourly_rollup_rows_after BIGINT NOT NULL DEFAULT 0, daily_rollup_rows_before BIGINT NOT NULL DEFAULT 0, daily_rollup_rows_after BIGINT NOT NULL DEFAULT 0, raw_status_rows_before BIGINT NOT NULL DEFAULT 0, raw_status_rows_after BIGINT NOT NULL DEFAULT 0, raw_status_rows_eligible_delete BIGINT NOT NULL DEFAULT 0, raw_status_rows_deleted BIGINT NOT NULL DEFAULT 0, started_at TIMESTAMPTZ NOT NULL, finished_at TIMESTAMPTZ NOT NULL, notes TEXT ); CREATE INDEX IF NOT EXISTS idx_minerops_retention_runs_started ON minerops_retention_runs(started_at DESC); """ ) def downgrade() -> None: raise RuntimeError( "0006 adopts pre-existing operational tables and is intentionally irreversible" )