package store import ( "context" "database/sql" "fmt" "os" "strconv" "time" ) // schemaSQL — схема таблицы устройств. // PRIMARY KEY (namespace, name) — как в REST-маршруте /devices/{name}. // Уникальность device_id в рамках namespace — для MQTT username. const schemaSQL = ` CREATE TABLE IF NOT EXISTS iot_devices ( namespace TEXT NOT NULL, name TEXT NOT NULL, device_id TEXT NOT NULL, enabled BOOLEAN NOT NULL DEFAULT true, mqtt_password TEXT NOT NULL, metadata JSONB, phase TEXT NOT NULL DEFAULT 'Active', last_connected TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (namespace, name) ); CREATE UNIQUE INDEX IF NOT EXISTS idx_iot_devices_ns_device ON iot_devices (namespace, device_id); ` // DeviceStore — доступ к таблице iot_devices (собственный пул соединений). type DeviceStore struct { db *sql.DB } // Open подключается к PostgreSQL по DSN и инициализирует схему. func Open(ctx context.Context, dsn string) (*DeviceStore, error) { db, err := sql.Open("postgres", dsn) if err != nil { return nil, fmt.Errorf("store: open DB: %w", err) } // Пул устройств: дефолт 20 (ревью 2026-08-16: 5 мало для API+MQTT auth), // переопределяется IOT_PG_MAX_CONNS. maxConns := 20 if v := os.Getenv("IOT_PG_MAX_CONNS"); v != "" { if n, convErr := strconv.Atoi(v); convErr == nil && n > 0 { maxConns = n } } db.SetMaxOpenConns(maxConns) db.SetMaxIdleConns(2) db.SetConnMaxLifetime(5 * time.Minute) pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() if err := db.PingContext(pingCtx); err != nil { db.Close() return nil, fmt.Errorf("store: ping DB: %w", err) } if _, err := db.ExecContext(ctx, schemaSQL); err != nil { db.Close() return nil, fmt.Errorf("store: init schema: %w", err) } return &DeviceStore{db: db}, nil } // Close закрывает пул соединений. func (s *DeviceStore) Close() error { return s.db.Close() }